import { readdirSync } from 'fs' import { Box, Text } from 'ink' import { basename, isAbsolute, join, relative, resolve, sep } from 'path' import * as React from 'react' import { z } from 'zod' import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage' import { Tool } from '../../Tool' import { logError } from '../../utils/log' import { getCwd } from '../../utils/state' import { getTheme } from '../../utils/theme' import { DESCRIPTION } from './prompt' import { hasReadPermission } from '../../utils/permissions/filesystem' const MAX_LINES = 5 const MAX_FILES = 1000 const TRUNCATED_MESSAGE = `There are more than ${MAX_FILES} files in the repository. Use the LS tool (passing a specific path), Bash tool, and other tools to explore nested directories. The first ${MAX_FILES} files and directories are included below:\n\n` const inputSchema = z.strictObject({ path: z .string() .describe( 'The absolute path to the directory to list (must be absolute, not relative)', ), }) // TODO: Kill this tool and use bash instead export const LSTool = { name: 'LS', async description() { return DESCRIPTION }, inputSchema, userFacingName() { return 'List' }, async isEnabled() { return true }, isReadOnly() { return true }, isConcurrencySafe() { return true // LSTool is read-only, safe for concurrent execution }, needsPermissions({ path }) { return !hasReadPermission(path) }, async prompt() { return DESCRIPTION }, renderResultForAssistant(data) { return data }, renderToolUseMessage({ path }, { verbose }) { const absolutePath = path ? isAbsolute(path) ? path : resolve(getCwd(), path) : undefined const relativePath = absolutePath ? relative(getCwd(), absolutePath) : '.' return `path: "${verbose ? path : relativePath}"` }, renderToolUseRejectedMessage() { return }, renderToolResultMessage(content) { const verbose = false // Set default value for verbose if (typeof content !== 'string') { return null } const result = content.replace(TRUNCATED_MESSAGE, '') if (!result) { return null } return (   ⎿   {result .split('\n') .filter(_ => _.trim() !== '') .slice(0, verbose ? undefined : MAX_LINES) .map((_, i) => ( {_} ))} {!verbose && result.split('\n').length > MAX_LINES && ( ... (+{result.split('\n').length - MAX_LINES} items) )} ) }, async *call({ path }, { abortController }) { const fullFilePath = isAbsolute(path) ? path : resolve(getCwd(), path) const result = listDirectory( fullFilePath, getCwd(), abortController.signal, ).sort() const safetyWarning = `\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work.` // Plain tree for user display without warning const userTree = printTree(createFileTree(result)) // Tree with safety warning for assistant only const assistantTree = userTree if (result.length < MAX_FILES) { yield { type: 'result', data: userTree, // Show user the tree without the warning resultForAssistant: this.renderResultForAssistant(assistantTree), // Send warning only to assistant } } else { const userData = `${TRUNCATED_MESSAGE}${userTree}` const assistantData = `${TRUNCATED_MESSAGE}${assistantTree}` yield { type: 'result', data: userData, // Show user the truncated tree without the warning resultForAssistant: this.renderResultForAssistant(assistantData), // Send warning only to assistant } } }, } satisfies Tool function listDirectory( initialPath: string, cwd: string, abortSignal: AbortSignal, ): string[] { const results: string[] = [] const queue = [initialPath] while (queue.length > 0) { if (results.length > MAX_FILES) { return results } if (abortSignal.aborted) { return results } const path = queue.shift()! if (skip(path)) { continue } if (path !== initialPath) { results.push(relative(cwd, path) + sep) } let children try { children = readdirSync(path, { withFileTypes: true }) } catch (e) { // eg. EPERM, EACCES, ENOENT, etc. logError(e) continue } for (const child of children) { if (child.isDirectory()) { queue.push(join(path, child.name) + sep) } else { const fileName = join(path, child.name) if (skip(fileName)) { continue } results.push(relative(cwd, fileName)) if (results.length > MAX_FILES) { return results } } } } return results } type TreeNode = { name: string path: string type: 'file' | 'directory' children?: TreeNode[] } function createFileTree(sortedPaths: string[]): TreeNode[] { const root: TreeNode[] = [] for (const path of sortedPaths) { const parts = path.split(sep) let currentLevel = root let currentPath = '' for (let i = 0; i < parts.length; i++) { const part = parts[i]! if (!part) { // directories have trailing slashes continue } currentPath = currentPath ? `${currentPath}${sep}${part}` : part const isLastPart = i === parts.length - 1 const existingNode = currentLevel.find(node => node.name === part) if (existingNode) { currentLevel = existingNode.children || [] } else { const newNode: TreeNode = { name: part, path: currentPath, type: isLastPart ? 'file' : 'directory', } if (!isLastPart) { newNode.children = [] } currentLevel.push(newNode) currentLevel = newNode.children || [] } } } return root } /** * eg. * - src/ * - index.ts * - utils/ * - file.ts */ function printTree(tree: TreeNode[], level = 0, prefix = ''): string { let result = '' // Add absolute path at root level if (level === 0) { result += `- ${getCwd()}${sep}\n` prefix = ' ' } for (const node of tree) { // Add the current node to the result result += `${prefix}${'-'} ${node.name}${node.type === 'directory' ? sep : ''}\n` // Recursively print children if they exist if (node.children && node.children.length > 0) { result += printTree(node.children, level + 1, `${prefix} `) } } return result } // TODO: Add windows support function skip(path: string): boolean { if (path !== '.' && basename(path).startsWith('.')) { return true } if (path.includes(`__pycache__${sep}`)) { return true } return false }