From e55209b46f5292285552251a169b5a4df50bb2f4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:14:05 -0500 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=A4=96=20Add=20file=5Flist=20tool?= =?UTF-8?q?=20with=20recursive=20structure=20and=20gitignore=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements recursive directory listing with depth control - Returns nested JSON structure for easy model interpretation - Respects .gitignore patterns by default (configurable) - Supports glob pattern filtering (*.ts, **/*.test.ts) - Enforces hard limit of 128 entries (fails fast vs truncate) - Always hides .git directory - Includes comprehensive test suite (18 tests) - UI component with tree visualization --- bun.lock | 45 ++- package.json | 3 + src/components/Messages/ToolMessage.tsx | 8 + .../tools/FileListToolCall.module.css | 122 ++++++ src/components/tools/FileListToolCall.tsx | 109 ++++++ src/constants/toolLimits.ts | 5 + src/services/tools/fileCommon.ts | 13 + src/services/tools/file_list.test.ts | 360 ++++++++++++++++++ src/services/tools/file_list.ts | 280 ++++++++++++++ src/types/tools.ts | 31 ++ src/utils/tools/toolDefinitions.ts | 40 ++ src/utils/tools/tools.ts | 2 + 12 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 src/components/tools/FileListToolCall.module.css create mode 100644 src/components/tools/FileListToolCall.tsx create mode 100644 src/services/tools/file_list.test.ts create mode 100644 src/services/tools/file_list.ts diff --git a/bun.lock b/bun.lock index 10f82bdd5..a45933c64 100644 --- a/bun.lock +++ b/bun.lock @@ -14,10 +14,12 @@ "crc-32": "^1.2.2", "diff": "^8.0.2", "disposablestack": "^1.1.7", + "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", "markdown-it": "^14.1.0", "mermaid": "^11.12.0", + "minimatch": "^10.0.3", "minimist": "^1.2.8", "posthog-js": "^1.276.0", "react": "^18.2.0", @@ -54,6 +56,7 @@ "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", + "@types/minimatch": "^6.0.0", "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -316,6 +319,10 @@ "@iconify/utils": ["@iconify/utils@3.0.2", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", "debug": "^4.4.1", "globals": "^15.15.0", "kolorist": "^1.8.0", "local-pkg": "^1.1.1", "mlly": "^1.7.4" } }, "sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], @@ -704,6 +711,8 @@ "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -1982,7 +1991,7 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2630,6 +2639,8 @@ "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@electron/asar/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -2638,6 +2649,8 @@ "@electron/universal/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@electron/universal/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -2648,8 +2661,12 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -2800,6 +2817,8 @@ "default-require-extensions/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], + "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], @@ -2808,6 +2827,10 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "eslint-plugin-react/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -3034,6 +3057,8 @@ "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "ts-jest/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "unzip-crx-3/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], @@ -3170,6 +3195,8 @@ "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "babel-jest/@jest/transform/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], "babel-jest/@jest/transform/jest-haste-map": ["jest-haste-map@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.2.0", "jest-worker": "30.2.0", "micromatch": "^4.0.8", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw=="], @@ -3300,6 +3327,8 @@ "jest-resolve/jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-runtime/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "jest-validate/@jest/types/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], "jest-watch-typeahead/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -3326,6 +3355,8 @@ "nyc/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "nyc/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "nyc/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "nyc/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], @@ -3338,6 +3369,8 @@ "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "string-length/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "wait-port/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -3418,6 +3451,8 @@ "create-jest/jest-config/babel-jest/babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], + "create-jest/jest-config/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "expect/jest-message-util/@jest/types/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], "expect/jest-mock/@jest/types/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], @@ -3526,6 +3561,8 @@ "wait-port/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@jest/core/@jest/transform/babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -3628,14 +3665,20 @@ "wait-port/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "@storybook/test-runner/jest/@jest/core/@jest/reporters/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@storybook/test-runner/jest/@jest/core/@jest/reporters/istanbul-lib-instrument/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@storybook/test-runner/jest/@jest/core/@jest/reporters/string-length/char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], "@storybook/test-runner/jest/@jest/core/jest-config/babel-jest/babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], + "@storybook/test-runner/jest/@jest/core/jest-config/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@storybook/test-runner/jest/jest-cli/jest-config/babel-jest/babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], + "@storybook/test-runner/jest/jest-cli/jest-config/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "jest-config/jest-circus/jest-runtime/@jest/transform/babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], "jest-config/jest-circus/jest-snapshot/@jest/transform/babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], diff --git a/package.json b/package.json index 1280fee60..3a4022a73 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,12 @@ "crc-32": "^1.2.2", "diff": "^8.0.2", "disposablestack": "^1.1.7", + "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", "markdown-it": "^14.1.0", "mermaid": "^11.12.0", + "minimatch": "^10.0.3", "minimist": "^1.2.8", "posthog-js": "^1.276.0", "react": "^18.2.0", @@ -83,6 +85,7 @@ "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", + "@types/minimatch": "^6.0.0", "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index ec2e2875a..b39246859 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -5,6 +5,7 @@ import { GenericToolCall } from "../tools/GenericToolCall"; import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; +import { FileListToolCall } from "../tools/FileListToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import { TodoToolCall } from "../tools/TodoToolCall"; import type { @@ -12,6 +13,8 @@ import type { BashToolResult, FileReadToolArgs, FileReadToolResult, + FileListToolArgs, + FileListToolResult, FileEditInsertToolArgs, FileEditInsertToolResult, FileEditReplaceStringToolArgs, @@ -42,6 +45,11 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success; } +function isFileListTool(toolName: string, args: unknown): args is FileListToolArgs { + if (toolName !== "file_list") return false; + return TOOL_DEFINITIONS.file_list.schema.safeParse(args).success; +} + function isFileEditReplaceStringTool( toolName: string, args: unknown diff --git a/src/components/tools/FileListToolCall.module.css b/src/components/tools/FileListToolCall.module.css new file mode 100644 index 000000000..116c49183 --- /dev/null +++ b/src/components/tools/FileListToolCall.module.css @@ -0,0 +1,122 @@ +.container { + font-family: var(--font-mono); + font-size: 13px; + border-radius: 6px; + background: var(--color-surface-elevated); + padding: 12px; + margin: 8px 0; +} + +.container.error { + border-left: 3px solid var(--color-error); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.toolName { + font-weight: 600; + color: var(--color-text-primary); +} + +.path { + color: var(--color-text-secondary); + font-weight: 500; +} + +.params { + color: var(--color-text-tertiary); + font-size: 12px; +} + +.count { + color: var(--color-text-tertiary); + font-size: 12px; + margin-left: auto; +} + +.status { + color: var(--color-text-tertiary); + padding: 8px; + font-style: italic; +} + +/* Error styling */ +.errorMessage { + background: var(--color-surface); + border-radius: 4px; + padding: 12px; + margin-top: 8px; +} + +.errorTitle { + font-weight: 600; + color: var(--color-error); + margin-bottom: 6px; +} + +.errorText { + color: var(--color-text-secondary); + line-height: 1.5; + white-space: pre-wrap; +} + +.errorHint { + color: var(--color-text-tertiary); + font-size: 12px; + margin-top: 8px; + font-style: italic; +} + +/* Tree styling */ +.treeContainer { + margin-top: 8px; + background: var(--color-surface); + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} + +.tree { + font-family: var(--font-mono); + line-height: 1.6; +} + +.entry { + display: flex; + align-items: center; + white-space: nowrap; +} + +.prefix { + color: var(--color-text-tertiary); + user-select: none; +} + +.icon { + margin-right: 6px; + user-select: none; +} + +.name { + color: var(--color-text-primary); + font-weight: 500; +} + +.size { + color: var(--color-text-tertiary); + margin-left: 8px; + font-size: 12px; +} + +.empty { + color: var(--color-text-tertiary); + font-style: italic; + text-align: center; + padding: 16px; +} diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx new file mode 100644 index 000000000..860b2ecdd --- /dev/null +++ b/src/components/tools/FileListToolCall.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools"; +import { formatSize } from "@/services/tools/fileCommon"; +import styles from "./FileListToolCall.module.css"; + +interface FileListToolCallProps { + args: FileListToolArgs; + result?: FileListToolResult; + status: "pending" | "streaming" | "complete" | "error"; +} + +/** + * Recursively render a file tree with indentation + */ +function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] { + const elements: JSX.Element[] = []; + + entries.forEach((entry, index) => { + const isLast = index === entries.length - 1; + const prefix = depth === 0 ? "" : "│ ".repeat(depth - 1) + (isLast ? "└─ " : "├─ "); + + const icon = entry.type === "directory" ? "📁" : entry.type === "file" ? "📄" : "🔗"; + const suffix = entry.type === "directory" ? "/" : ""; + const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : ""; + + elements.push( +
+ {prefix} + {icon} + + {entry.name} + {suffix} + + {sizeInfo && {sizeInfo}} +
+ ); + + // Recursively render children if present + if (entry.children && entry.children.length > 0) { + elements.push(...renderFileTree(entry.children, depth + 1)); + } + }); + + return elements; +} + +export function FileListToolCall({ args, result, status }: FileListToolCallProps): JSX.Element { + const isError = status === "error" || (result && !result.success); + const isComplete = status === "complete"; + const isPending = status === "pending" || status === "streaming"; + + // Build parameter summary + const params: string[] = []; + if (args.max_depth !== undefined && args.max_depth !== 1) { + params.push(`depth: ${args.max_depth}`); + } + if (args.pattern) { + params.push(`pattern: ${args.pattern}`); + } + if (args.gitignore === false) { + params.push("gitignore: off"); + } + if (args.max_entries) { + params.push(`max: ${args.max_entries}`); + } + + const paramStr = params.length > 0 ? ` (${params.join(", ")})` : ""; + + return ( +
+ {/* Header */} +
+ 📋 file_list: + {args.path} + {paramStr} + {isComplete && result && result.success && ( + {result.total_count} entries + )} +
+ + {/* Status */} + {isPending &&
⏳ Listing directory...
} + + {/* Error */} + {isError && result && !result.success && ( +
+
❌ Error
+
{result.error}
+ {result.total_found !== undefined && ( +
+ Found {result.total_found}+ entries (limit: {result.limit_requested}) +
+ )} +
+ )} + + {/* Success - Render tree */} + {isComplete && result && result.success && ( +
+ {result.entries.length === 0 ? ( +
Empty directory
+ ) : ( +
{renderFileTree(result.entries)}
+ )} +
+ )} +
+ ); +} diff --git a/src/constants/toolLimits.ts b/src/constants/toolLimits.ts index 4c4089243..34aff74c5 100644 --- a/src/constants/toolLimits.ts +++ b/src/constants/toolLimits.ts @@ -5,4 +5,9 @@ export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line export const BASH_MAX_TOTAL_BYTES = 16 * 1024; // 16KB total output to show agent export const BASH_MAX_FILE_BYTES = 100 * 1024; // 100KB max to save to temp file +export const FILE_LIST_DEFAULT_DEPTH = 1; // Non-recursive by default +export const FILE_LIST_MAX_DEPTH = 10; // Allow deep traversal when needed +export const FILE_LIST_DEFAULT_MAX_ENTRIES = 100; // Reasonable default +export const FILE_LIST_HARD_MAX_ENTRIES = 128; // Absolute limit (prevent context overload) + export const MAX_TODOS = 7; // Maximum number of TODO items in a list diff --git a/src/services/tools/fileCommon.ts b/src/services/tools/fileCommon.ts index 28c18713a..8f062a9bb 100644 --- a/src/services/tools/fileCommon.ts +++ b/src/services/tools/fileCommon.ts @@ -88,3 +88,16 @@ export function validatePathInCwd(filePath: string, cwd: string): { error: strin return null; } + +/** + * Format a file size in bytes to a human-readable string. + * Uses KB for sizes >= 1KB, MB for sizes >= 1MB, otherwise bytes. + * + * @param bytes - File size in bytes + * @returns Formatted size string (e.g., "1.5KB", "2.3MB", "512B") + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts new file mode 100644 index 000000000..8f833e1ec --- /dev/null +++ b/src/services/tools/file_list.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import { createFileListTool } from "./file_list"; +import type { FileListToolArgs, FileListToolResult } from "@/types/tools"; + +describe("file_list tool", () => { + let tempDir: string; + let tool: ReturnType; + + beforeEach(async () => { + // Create a temporary directory for tests + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "file-list-test-")); + tool = createFileListTool({ cwd: tempDir }); + }); + + afterEach(async () => { + // Clean up temporary directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("basic functionality", () => { + test("lists files in a directory (depth 1)", async () => { + // Create test structure + await fs.writeFile(path.join(tempDir, "file1.txt"), "content1"); + await fs.writeFile(path.join(tempDir, "file2.txt"), "content2"); + await fs.mkdir(path.join(tempDir, "subdir")); + + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.length).toBe(3); + expect(result.total_count).toBe(3); + expect(result.depth_used).toBe(1); + + // Check sorting: directories first + expect(result.entries[0].name).toBe("subdir"); + expect(result.entries[0].type).toBe("directory"); + expect(result.entries[1].name).toBe("file1.txt"); + expect(result.entries[1].type).toBe("file"); + expect(result.entries[2].name).toBe("file2.txt"); + }); + + test("lists files recursively (depth 2)", async () => { + // Create nested structure + await fs.mkdir(path.join(tempDir, "dir1")); + await fs.writeFile(path.join(tempDir, "dir1", "file1.txt"), "content"); + await fs.writeFile(path.join(tempDir, "root.txt"), "root"); + + const result = (await tool.execute!( + { + path: ".", + max_depth: 2, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.total_count).toBe(3); // dir1, dir1/file1.txt, root.txt + expect(result.depth_used).toBe(2); + + const dir1 = result.entries.find((e) => e.name === "dir1"); + expect(dir1).toBeDefined(); + expect(dir1!.children).toBeDefined(); + expect(dir1!.children!.length).toBe(1); + expect(dir1!.children![0].name).toBe("file1.txt"); + }); + + test("shows file sizes", async () => { + const content = "a".repeat(100); + await fs.writeFile(path.join(tempDir, "file.txt"), content); + + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries[0].size).toBe(100); + }); + + test("empty directory", async () => { + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.length).toBe(0); + expect(result.total_count).toBe(0); + }); + }); + + describe("pattern filtering", () => { + test("filters by pattern (*.ts)", async () => { + await fs.writeFile(path.join(tempDir, "file1.ts"), "ts"); + await fs.writeFile(path.join(tempDir, "file2.js"), "js"); + await fs.writeFile(path.join(tempDir, "file3.ts"), "ts"); + + const result = (await tool.execute!( + { + path: ".", + pattern: "*.ts", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.length).toBe(2); + expect(result.entries.every((e) => e.name.endsWith(".ts"))).toBe(true); + }); + + test("prunes empty directories when using pattern", async () => { + // Create structure where some dirs have no matches + await fs.mkdir(path.join(tempDir, "hasTs")); + await fs.writeFile(path.join(tempDir, "hasTs", "file.ts"), "ts"); + await fs.mkdir(path.join(tempDir, "noTs")); + await fs.writeFile(path.join(tempDir, "noTs", "file.js"), "js"); + + const result = (await tool.execute!( + { + path: ".", + pattern: "*.ts", + max_depth: 2, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + // Should only include hasTs directory (not noTs) + expect(result.entries.length).toBe(1); + expect(result.entries[0].name).toBe("hasTs"); + }); + }); + + describe("gitignore filtering", () => { + test("respects .gitignore by default", async () => { + // Create .gitignore + await fs.writeFile(path.join(tempDir, ".gitignore"), "ignored.txt\nnode_modules/\n"); + + // Create files + await fs.writeFile(path.join(tempDir, "included.txt"), "inc"); + await fs.writeFile(path.join(tempDir, "ignored.txt"), "ign"); + await fs.mkdir(path.join(tempDir, "node_modules")); + await fs.writeFile(path.join(tempDir, "node_modules", "pkg.json"), "{}"); + + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + // Should include .gitignore and included.txt, but not ignored.txt or node_modules + expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true); + expect(result.entries.some((e) => e.name === "included.txt")).toBe(true); + expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(false); + expect(result.entries.some((e) => e.name === "node_modules")).toBe(false); + }); + + test("shows all files when gitignore=false", async () => { + await fs.writeFile(path.join(tempDir, ".gitignore"), "ignored.txt\n"); + await fs.writeFile(path.join(tempDir, "included.txt"), "inc"); + await fs.writeFile(path.join(tempDir, "ignored.txt"), "ign"); + + const result = (await tool.execute!( + { + path: ".", + gitignore: false, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(true); + }); + + test("always hides .git directory", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile(path.join(tempDir, ".git", "config"), "git"); + await fs.writeFile(path.join(tempDir, "file.txt"), "content"); + + const result = (await tool.execute!( + { + path: ".", + gitignore: false, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.some((e) => e.name === ".git")).toBe(false); + }); + + test("shows hidden files (dotfiles)", async () => { + await fs.writeFile(path.join(tempDir, ".env"), "secret"); + await fs.writeFile(path.join(tempDir, ".gitignore"), "*.log"); + await fs.writeFile(path.join(tempDir, "file.txt"), "content"); + + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.entries.some((e) => e.name === ".env")).toBe(true); + expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true); + }); + }); + + describe("limit enforcement", () => { + test("returns error when exceeding default limit", async () => { + // Create 101 files (exceeds default limit of 100) + for (let i = 0; i < 101; i++) { + await fs.writeFile(path.join(tempDir, `file${i}.txt`), `content${i}`); + } + + const result = (await tool.execute!( + { + path: ".", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(false); + expect(result.error).toContain("exceed limit"); + expect(result.total_found).toBeGreaterThan(100); + expect(result.limit_requested).toBe(100); + }); + + test("respects custom max_entries", async () => { + for (let i = 0; i < 20; i++) { + await fs.writeFile(path.join(tempDir, `file${i}.txt`), "content"); + } + + const result = (await tool.execute!( + { + path: ".", + max_entries: 10, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(false); + expect(result.limit_requested).toBe(10); + }); + + test("enforces hard cap of 128 entries", async () => { + for (let i = 0; i < 10; i++) { + await fs.writeFile(path.join(tempDir, `file${i}.txt`), "content"); + } + + const result = (await tool.execute!( + { + path: ".", + max_entries: 200, // Try to exceed hard cap + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + // Should work since we're under 128 + }); + }); + + describe("error handling", () => { + test("returns error for non-existent path", async () => { + const result = (await tool.execute!( + { + path: "nonexistent", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + }); + + test("returns error for file path (not directory)", async () => { + await fs.writeFile(path.join(tempDir, "file.txt"), "content"); + + const result = (await tool.execute!( + { + path: "file.txt", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(false); + expect(result.error).toContain("not a directory"); + }); + + test("returns error for path outside cwd", async () => { + const result = (await tool.execute!( + { + path: "..", + }, + {} as any + )) as Extract; + + expect(result.success).toBe(false); + expect(result.error).toContain("outside"); + }); + }); + + describe("depth limits", () => { + test("enforces max depth of 10", async () => { + // Create deep nesting + let currentPath = tempDir; + for (let i = 0; i < 12; i++) { + currentPath = path.join(currentPath, `level${i}`); + await fs.mkdir(currentPath); + await fs.writeFile(path.join(currentPath, "file.txt"), `level${i}`); + } + + const result = (await tool.execute!( + { + path: ".", + max_depth: 15, // Try to exceed max + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + expect(result.depth_used).toBe(10); // Clamped to max + }); + + test("depth 1 does not traverse into subdirectories", async () => { + await fs.mkdir(path.join(tempDir, "dir1")); + await fs.writeFile(path.join(tempDir, "dir1", "nested.txt"), "nested"); + await fs.writeFile(path.join(tempDir, "root.txt"), "root"); + + const result = (await tool.execute!( + { + path: ".", + max_depth: 1, + }, + {} as any + )) as Extract; + + expect(result.success).toBe(true); + const dir = result.entries.find((e) => e.name === "dir1"); + expect(dir).toBeDefined(); + expect(dir!.children).toBeUndefined(); // No children at depth 1 + }); + }); +}); diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts new file mode 100644 index 000000000..1b41def9c --- /dev/null +++ b/src/services/tools/file_list.ts @@ -0,0 +1,280 @@ +import { tool } from "ai"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { minimatch } from "minimatch"; +import ignore from "ignore"; +import type { FileEntry, FileListToolArgs, FileListToolResult } from "@/types/tools"; +import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; +import { validatePathInCwd } from "./fileCommon"; +import { + FILE_LIST_DEFAULT_DEPTH, + FILE_LIST_DEFAULT_MAX_ENTRIES, + FILE_LIST_HARD_MAX_ENTRIES, + FILE_LIST_MAX_DEPTH, +} from "@/constants/toolLimits"; + +interface TraversalOptions { + pattern?: string; + useGitignore: boolean; + maxEntries: number; + ig: ReturnType | null; + rootPath: string; +} + +interface TraversalResult { + entries: FileEntry[]; + totalCount: number; + exceeded: boolean; +} + +/** + * Recursively build a file tree structure with depth control and filtering. + * Counts entries as they're added and stops when limit is reached. + * + * @param dir - Directory to traverse + * @param currentDepth - Current depth level (1 = immediate children) + * @param maxDepth - Maximum depth to traverse + * @param options - Filtering options (pattern, gitignore, entry limit) + * @param currentCount - Shared counter tracking total entries across recursion + * @returns Tree structure with entries, total count, and exceeded flag + */ +async function buildFileTree( + dir: string, + currentDepth: number, + maxDepth: number, + options: TraversalOptions, + currentCount: { value: number } +): Promise { + // Check if we've already exceeded the limit + if (currentCount.value >= options.maxEntries) { + return { entries: [], totalCount: currentCount.value, exceeded: true }; + } + + let dirents; + try { + dirents = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + // If we can't read the directory (permissions, etc.), skip it + return { entries: [], totalCount: currentCount.value, exceeded: false }; + } + + // Sort: directories first, then files, alphabetically within each group + dirents.sort((a, b) => { + const aIsDir = a.isDirectory(); + const bIsDir = b.isDirectory(); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.name.localeCompare(b.name); + }); + + const entries: FileEntry[] = []; + + for (const dirent of dirents) { + const fullPath = path.join(dir, dirent.name); + const entryType = dirent.isDirectory() ? "directory" : dirent.isFile() ? "file" : "symlink"; + + // Always skip .git directory regardless of gitignore setting + if (dirent.name === ".git" && entryType === "directory") { + continue; + } + + // Check gitignore filtering + if (options.useGitignore && options.ig) { + const relativePath = path.relative(options.rootPath, fullPath); + // Add trailing slash for directories for proper gitignore matching + const pathToCheck = entryType === "directory" ? relativePath + "/" : relativePath; + if (options.ig.ignores(pathToCheck)) { + continue; + } + } + + // For pattern matching: + // - If it's a file, check if it matches the pattern + // - If it's a directory, we'll add it provisionally and remove it later if it has no matches + let matchesPattern = true; + if (options.pattern && entryType === "file") { + matchesPattern = minimatch(dirent.name, options.pattern, { matchBase: true }); + } + + // Skip files that don't match pattern + if (entryType === "file" && !matchesPattern) { + continue; + } + + // Check limit before adding (even for directories we'll explore) + if (currentCount.value >= options.maxEntries) { + return { entries, totalCount: currentCount.value + 1, exceeded: true }; + } + + // Increment counter + currentCount.value++; + + const entry: FileEntry = { + name: dirent.name, + type: entryType, + }; + + // Get size for files + if (entryType === "file") { + try { + const stats = await fs.stat(fullPath); + entry.size = stats.size; + } catch { + // If we can't stat the file, skip size + } + } + + // Recurse into directories if within depth limit + if (entryType === "directory" && currentDepth < maxDepth) { + const result = await buildFileTree( + fullPath, + currentDepth + 1, + maxDepth, + options, + currentCount + ); + + if (result.exceeded) { + // Don't add this directory since we exceeded the limit while processing it + currentCount.value--; // Revert the increment for this directory + return { entries, totalCount: result.totalCount, exceeded: true }; + } + + entry.children = result.entries; + + // If we have a pattern and this directory has no matching descendants, skip it + if (options.pattern && entry.children.length === 0) { + currentCount.value--; // Revert the increment + continue; + } + } + + entries.push(entry); + } + + return { entries, totalCount: currentCount.value, exceeded: false }; +} + +/** + * Load and parse .gitignore file if it exists + */ +async function loadGitignore(rootPath: string): Promise | null> { + const gitignorePath = path.join(rootPath, ".gitignore"); + try { + const content = await fs.readFile(gitignorePath, "utf-8"); + const ig = ignore(); + ig.add(content); + return ig; + } catch { + // No .gitignore file, return empty ignore instance + return ignore(); + } +} + +/** + * Creates the file_list tool for listing directory contents with recursive traversal. + * + * Features: + * - Non-recursive by default (depth: 1) + * - Optional pattern filtering with glob support + * - Respects .gitignore by default + * - Hard limit enforcement (returns error instead of truncating) + * - Sorted output (directories first, then files, alphabetically) + * + * @param config - Tool configuration with cwd + * @returns Tool definition for file_list + */ +export function createFileListTool(config: { cwd: string }) { + return tool({ + description: TOOL_DEFINITIONS.file_list.description, + inputSchema: TOOL_DEFINITIONS.file_list.schema, + execute: async (args, { abortSignal: _abortSignal }): Promise => { + const { + path: targetPath, + max_depth = FILE_LIST_DEFAULT_DEPTH, + pattern, + gitignore = true, + max_entries = FILE_LIST_DEFAULT_MAX_ENTRIES, + } = args; + + // Validate path is within cwd + const pathError = validatePathInCwd(targetPath, config.cwd); + if (pathError) { + return { success: false, error: pathError.error }; + } + + // Resolve to absolute path + const resolvedPath = path.isAbsolute(targetPath) + ? path.resolve(targetPath) + : path.resolve(config.cwd, targetPath); + + // Check if path exists and is a directory + let stats; + try { + stats = await fs.stat(resolvedPath); + } catch (err) { + return { + success: false, + error: `Path does not exist: ${targetPath}`, + }; + } + + if (!stats.isDirectory()) { + return { + success: false, + error: `Path is not a directory: ${targetPath}`, + }; + } + + // Enforce depth limit + const effectiveDepth = Math.min(Math.max(1, max_depth), FILE_LIST_MAX_DEPTH); + + // Enforce entry limit + const effectiveMaxEntries = Math.min(Math.max(1, max_entries), FILE_LIST_HARD_MAX_ENTRIES); + + // Load .gitignore if requested + const ig = gitignore ? await loadGitignore(resolvedPath) : null; + + // Build the file tree + const currentCount = { value: 0 }; + const result = await buildFileTree( + resolvedPath, + 1, + effectiveDepth, + { + pattern, + useGitignore: gitignore, + maxEntries: effectiveMaxEntries, + ig, + rootPath: resolvedPath, + }, + currentCount + ); + + // If we exceeded the limit, return an error with guidance + if (result.exceeded) { + const errorMsg = [ + `Directory listing would exceed limit of ${effectiveMaxEntries} entries.`, + `Found ${result.totalCount}+ total entries.`, + `Use max_entries parameter to set a higher limit (max: ${FILE_LIST_HARD_MAX_ENTRIES})`, + `or narrow your search with pattern/depth.`, + ].join(" "); + + return { + success: false, + error: errorMsg, + total_found: result.totalCount, + limit_requested: effectiveMaxEntries, + }; + } + + return { + success: true, + path: resolvedPath, + entries: result.entries, + total_count: result.totalCount, + depth_used: effectiveDepth, + }; + }, + }); +} diff --git a/src/types/tools.ts b/src/types/tools.ts index 0173acb4b..6e2e36d8c 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -155,3 +155,34 @@ export interface TodoWriteToolResult { export interface TodoReadToolResult { todos: TodoItem[]; } + +// File List Tool Types +export interface FileListToolArgs { + path: string; + max_depth?: number; + pattern?: string; + gitignore?: boolean; + max_entries?: number; +} + +export interface FileEntry { + name: string; + type: "file" | "directory" | "symlink"; + size?: number; // bytes (files only) + children?: FileEntry[]; // directories only (when depth allows traversal) +} + +export type FileListToolResult = + | { + success: true; + path: string; // Resolved absolute path that was listed + entries: FileEntry[]; // Top-level entries (recursive structure) + total_count: number; // Total entries across all levels + depth_used: number; // Maximum depth traversed + } + | { + success: false; + error: string; + total_found?: number; + limit_requested?: number; + }; diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 36288f17e..101b9efb5 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -11,6 +11,9 @@ import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, BASH_MAX_TOTAL_BYTES, + FILE_LIST_DEFAULT_MAX_ENTRIES, + FILE_LIST_HARD_MAX_ENTRIES, + FILE_LIST_MAX_DEPTH, } from "@/constants/toolLimits"; import { zodToJsonSchema } from "zod-to-json-schema"; @@ -66,6 +69,42 @@ export const TOOL_DEFINITIONS = { .describe("Number of lines to return from offset (optional, returns all if not specified)"), }), }, + file_list: { + description: + "List files and directories in a path with optional recursion and filtering. " + + "Results show recursive tree structure with file sizes. " + + "Respects .gitignore by default (set gitignore=false to show all files). " + + `Prefer using minimal max_entries values (10-50) to avoid context waste - ` + + "make multiple focused calls rather than requesting large listings. " + + "Use pattern parameter to filter (supports glob patterns like '*.ts' or '**/*.test.ts').", + schema: z.object({ + path: z.string().describe("Directory path to list"), + max_depth: z + .number() + .int() + .min(1) + .max(FILE_LIST_MAX_DEPTH) + .optional() + .describe("Maximum depth to traverse (default: 1 for non-recursive)"), + pattern: z + .string() + .optional() + .describe("Glob pattern to filter entries (e.g., '*.ts', '**/*.test.ts')"), + gitignore: z + .boolean() + .optional() + .describe("Respect .gitignore patterns (default: true). Set to false to see all files."), + max_entries: z + .number() + .int() + .min(1) + .max(FILE_LIST_HARD_MAX_ENTRIES) + .optional() + .describe( + `Maximum entries to return (default: ${FILE_LIST_DEFAULT_MAX_ENTRIES}, max: ${FILE_LIST_HARD_MAX_ENTRIES}). Prefer lower values.` + ), + }), + }, file_edit_replace_string: { description: "Apply one or more edits to a file by replacing exact text matches. All edits are applied sequentially. Each old_string must be unique in the file unless replace_count > 1 or replace_count is -1.", @@ -225,6 +264,7 @@ export function getAvailableTools(modelString: string): string[] { const baseTools = [ "bash", "file_read", + "file_list", "file_edit_replace_string", // "file_edit_replace_lines", // DISABLED: causes models to break repo state "file_edit_insert", diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index 923ccfb6b..1331ed91e 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -1,5 +1,6 @@ import { type Tool } from "ai"; import { createFileReadTool } from "@/services/tools/file_read"; +import { createFileListTool } from "@/services/tools/file_list"; import { createBashTool } from "@/services/tools/bash"; import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_replace_string"; // DISABLED: import { createFileEditReplaceLinesTool } from "@/services/tools/file_edit_replace_lines"; @@ -50,6 +51,7 @@ export async function getToolsForModel( const baseTools: Record = { // Use snake_case for tool names to match what seems to be the convention. file_read: createFileReadTool(config), + file_list: createFileListTool(config), file_edit_replace_string: createFileEditReplaceStringTool(config), // DISABLED: file_edit_replace_lines - causes models (particularly GPT-5-Codex) // to leave repository in broken state due to issues with concurrent file modifications From 17fc9d413bb1ee736e724a3b4634d821e403d5e1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:24:38 -0500 Subject: [PATCH 02/16] Refactor to use Emotion CSS and reduce default limit to 64 - Replace CSS modules with Emotion styled components (matches project pattern) - Use shared ToolPrimitives and toolUtils for consistent styling - Reduce DEFAULT_MAX_ENTRIES from 100 to 64 - Update tests to reflect new limit --- .../tools/FileListToolCall.module.css | 122 ----------- src/components/tools/FileListToolCall.tsx | 199 ++++++++++++++---- src/constants/toolLimits.ts | 2 +- src/services/tools/file_list.test.ts | 8 +- 4 files changed, 161 insertions(+), 170 deletions(-) delete mode 100644 src/components/tools/FileListToolCall.module.css diff --git a/src/components/tools/FileListToolCall.module.css b/src/components/tools/FileListToolCall.module.css deleted file mode 100644 index 116c49183..000000000 --- a/src/components/tools/FileListToolCall.module.css +++ /dev/null @@ -1,122 +0,0 @@ -.container { - font-family: var(--font-mono); - font-size: 13px; - border-radius: 6px; - background: var(--color-surface-elevated); - padding: 12px; - margin: 8px 0; -} - -.container.error { - border-left: 3px solid var(--color-error); -} - -.header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - flex-wrap: wrap; -} - -.toolName { - font-weight: 600; - color: var(--color-text-primary); -} - -.path { - color: var(--color-text-secondary); - font-weight: 500; -} - -.params { - color: var(--color-text-tertiary); - font-size: 12px; -} - -.count { - color: var(--color-text-tertiary); - font-size: 12px; - margin-left: auto; -} - -.status { - color: var(--color-text-tertiary); - padding: 8px; - font-style: italic; -} - -/* Error styling */ -.errorMessage { - background: var(--color-surface); - border-radius: 4px; - padding: 12px; - margin-top: 8px; -} - -.errorTitle { - font-weight: 600; - color: var(--color-error); - margin-bottom: 6px; -} - -.errorText { - color: var(--color-text-secondary); - line-height: 1.5; - white-space: pre-wrap; -} - -.errorHint { - color: var(--color-text-tertiary); - font-size: 12px; - margin-top: 8px; - font-style: italic; -} - -/* Tree styling */ -.treeContainer { - margin-top: 8px; - background: var(--color-surface); - border-radius: 4px; - padding: 12px; - overflow-x: auto; -} - -.tree { - font-family: var(--font-mono); - line-height: 1.6; -} - -.entry { - display: flex; - align-items: center; - white-space: nowrap; -} - -.prefix { - color: var(--color-text-tertiary); - user-select: none; -} - -.icon { - margin-right: 6px; - user-select: none; -} - -.name { - color: var(--color-text-primary); - font-weight: 500; -} - -.size { - color: var(--color-text-tertiary); - margin-left: 8px; - font-size: 12px; -} - -.empty { - color: var(--color-text-tertiary); - font-style: italic; - text-align: center; - padding: 16px; -} diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index 860b2ecdd..154478798 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -1,7 +1,101 @@ import React from "react"; +import styled from "@emotion/styled"; import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools"; import { formatSize } from "@/services/tools/fileCommon"; -import styles from "./FileListToolCall.module.css"; +import { + ToolContainer, + ToolHeader, + ExpandIcon, + StatusIndicator, + ToolDetails, + DetailSection, + DetailLabel, + LoadingDots, +} from "./shared/ToolPrimitives"; +import { useToolExpansion, getStatusDisplay } from "./shared/toolUtils"; + +// FileList-specific styled components + +const PathText = styled.span` + color: var(--color-text); + font-family: var(--font-monospace); + font-weight: 500; +`; + +const ParamsText = styled.span` + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 8px; +`; + +const CountBadge = styled.span` + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 8px; +`; + +const ErrorMessage = styled.div` + color: #f44336; + font-size: 11px; + padding: 6px 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 3px; + border-left: 2px solid #f44336; + line-height: 1.5; + white-space: pre-wrap; +`; + +const ErrorHint = styled.div` + color: var(--color-text-secondary); + font-size: 10px; + margin-top: 6px; + font-style: italic; +`; + +const TreeContainer = styled.div` + margin-top: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + padding: 12px; + overflow-x: auto; + font-family: var(--font-monospace); + line-height: 1.6; +`; + +const Entry = styled.div` + display: flex; + align-items: center; + white-space: nowrap; + font-size: 11px; +`; + +const Prefix = styled.span` + color: var(--color-text-secondary); + user-select: none; +`; + +const Icon = styled.span` + margin-right: 6px; + user-select: none; +`; + +const Name = styled.span` + color: var(--color-text); + font-weight: 500; +`; + +const Size = styled.span` + color: var(--color-text-secondary); + margin-left: 8px; + font-size: 10px; +`; + +const EmptyMessage = styled.div` + color: var(--color-text-secondary); + font-style: italic; + text-align: center; + padding: 16px; +`; interface FileListToolCallProps { args: FileListToolArgs; @@ -24,15 +118,15 @@ function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : ""; elements.push( -
- {prefix} - {icon} - + + {prefix} + {icon} + {entry.name} {suffix} - - {sizeInfo && {sizeInfo}} -
+ + {sizeInfo && {sizeInfo}} + ); // Recursively render children if present @@ -44,7 +138,8 @@ function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] return elements; } -export function FileListToolCall({ args, result, status }: FileListToolCallProps): JSX.Element { +export const FileListToolCall: React.FC = ({ args, result, status }) => { + const { expanded, toggleExpanded } = useToolExpansion(false); const isError = status === "error" || (result && !result.success); const isComplete = status === "complete"; const isPending = status === "pending" || status === "streaming"; @@ -64,46 +159,64 @@ export function FileListToolCall({ args, result, status }: FileListToolCallProps params.push(`max: ${args.max_entries}`); } - const paramStr = params.length > 0 ? ` (${params.join(", ")})` : ""; + const paramStr = params.length > 0 ? `(${params.join(", ")})` : ""; + + // Convert our status to shared ToolStatus type + const toolStatus = isError ? "failed" : isPending ? "executing" : "completed"; return ( -
- {/* Header */} -
- 📋 file_list: - {args.path} - {paramStr} + + + + 📋 file_list + {args.path} + {paramStr && {paramStr}} {isComplete && result && result.success && ( - {result.total_count} entries + {result.total_count} entries )} -
- - {/* Status */} - {isPending &&
⏳ Listing directory...
} - - {/* Error */} - {isError && result && !result.success && ( -
-
❌ Error
-
{result.error}
- {result.total_found !== undefined && ( -
- Found {result.total_found}+ entries (limit: {result.limit_requested}) -
+ {getStatusDisplay(toolStatus)} + + + {expanded && ( + + {/* Pending state */} + {isPending && ( + + Listing directory + + + )} + + {/* Error state */} + {isError && result && !result.success && ( + + Error + + {result.error} + {result.total_found !== undefined && ( + + Found {result.total_found}+ entries (limit: {result.limit_requested}) + + )} + + )} -
- )} - {/* Success - Render tree */} - {isComplete && result && result.success && ( -
- {result.entries.length === 0 ? ( -
Empty directory
- ) : ( -
{renderFileTree(result.entries)}
+ {/* Success state */} + {isComplete && result && result.success && ( + + Contents ({result.total_count} entries) + + {result.entries.length === 0 ? ( + Empty directory + ) : ( + <>{renderFileTree(result.entries)} + )} + + )} -
+ )} -
+ ); -} +}; diff --git a/src/constants/toolLimits.ts b/src/constants/toolLimits.ts index 34aff74c5..ef9304358 100644 --- a/src/constants/toolLimits.ts +++ b/src/constants/toolLimits.ts @@ -7,7 +7,7 @@ export const BASH_MAX_FILE_BYTES = 100 * 1024; // 100KB max to save to temp file export const FILE_LIST_DEFAULT_DEPTH = 1; // Non-recursive by default export const FILE_LIST_MAX_DEPTH = 10; // Allow deep traversal when needed -export const FILE_LIST_DEFAULT_MAX_ENTRIES = 100; // Reasonable default +export const FILE_LIST_DEFAULT_MAX_ENTRIES = 64; // Reasonable default export const FILE_LIST_HARD_MAX_ENTRIES = 128; // Absolute limit (prevent context overload) export const MAX_TODOS = 7; // Maximum number of TODO items in a list diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index 8f833e1ec..dddc6e169 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -223,8 +223,8 @@ describe("file_list tool", () => { describe("limit enforcement", () => { test("returns error when exceeding default limit", async () => { - // Create 101 files (exceeds default limit of 100) - for (let i = 0; i < 101; i++) { + // Create 65 files (exceeds default limit of 64) + for (let i = 0; i < 65; i++) { await fs.writeFile(path.join(tempDir, `file${i}.txt`), `content${i}`); } @@ -237,8 +237,8 @@ describe("file_list tool", () => { expect(result.success).toBe(false); expect(result.error).toContain("exceed limit"); - expect(result.total_found).toBeGreaterThan(100); - expect(result.limit_requested).toBe(100); + expect(result.total_found).toBeGreaterThan(64); + expect(result.limit_requested).toBe(64); }); test("respects custom max_entries", async () => { From b0b52afd416e520196e686d6293a19bef8f57cb4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:24:52 -0500 Subject: [PATCH 03/16] Add clarifying comments for ig parameter The ig (ignore instance) is loaded once from .gitignore and passed through recursion for efficiency. This avoids re-parsing .gitignore at every directory level. --- src/services/tools/file_list.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 1b41def9c..1f5d3cdf6 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -17,8 +17,8 @@ interface TraversalOptions { pattern?: string; useGitignore: boolean; maxEntries: number; - ig: ReturnType | null; - rootPath: string; + ig: ReturnType | null; // Ignore instance loaded once from .gitignore, reused across recursion + rootPath: string; // Root path for calculating relative paths in gitignore matching } interface TraversalResult { From 897e89d6b65ab080084efb98520a1f543555ff4f Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:29:20 -0500 Subject: [PATCH 04/16] Refactor: use closure for gitignore instance instead of parameter - Remove ig and rootPath from TraversalOptions interface - Convert buildFileTree to inner function that captures these via closure - Cleaner interface - only exposes actual options, not implementation details - Same efficiency - still loads .gitignore once - Net: -5 lines --- src/services/tools/file_list.ts | 269 ++++++++++++++++---------------- 1 file changed, 134 insertions(+), 135 deletions(-) diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 1f5d3cdf6..38eaff5d3 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -15,10 +15,7 @@ import { interface TraversalOptions { pattern?: string; - useGitignore: boolean; maxEntries: number; - ig: ReturnType | null; // Ignore instance loaded once from .gitignore, reused across recursion - rootPath: string; // Root path for calculating relative paths in gitignore matching } interface TraversalResult { @@ -27,134 +24,6 @@ interface TraversalResult { exceeded: boolean; } -/** - * Recursively build a file tree structure with depth control and filtering. - * Counts entries as they're added and stops when limit is reached. - * - * @param dir - Directory to traverse - * @param currentDepth - Current depth level (1 = immediate children) - * @param maxDepth - Maximum depth to traverse - * @param options - Filtering options (pattern, gitignore, entry limit) - * @param currentCount - Shared counter tracking total entries across recursion - * @returns Tree structure with entries, total count, and exceeded flag - */ -async function buildFileTree( - dir: string, - currentDepth: number, - maxDepth: number, - options: TraversalOptions, - currentCount: { value: number } -): Promise { - // Check if we've already exceeded the limit - if (currentCount.value >= options.maxEntries) { - return { entries: [], totalCount: currentCount.value, exceeded: true }; - } - - let dirents; - try { - dirents = await fs.readdir(dir, { withFileTypes: true }); - } catch (err) { - // If we can't read the directory (permissions, etc.), skip it - return { entries: [], totalCount: currentCount.value, exceeded: false }; - } - - // Sort: directories first, then files, alphabetically within each group - dirents.sort((a, b) => { - const aIsDir = a.isDirectory(); - const bIsDir = b.isDirectory(); - if (aIsDir && !bIsDir) return -1; - if (!aIsDir && bIsDir) return 1; - return a.name.localeCompare(b.name); - }); - - const entries: FileEntry[] = []; - - for (const dirent of dirents) { - const fullPath = path.join(dir, dirent.name); - const entryType = dirent.isDirectory() ? "directory" : dirent.isFile() ? "file" : "symlink"; - - // Always skip .git directory regardless of gitignore setting - if (dirent.name === ".git" && entryType === "directory") { - continue; - } - - // Check gitignore filtering - if (options.useGitignore && options.ig) { - const relativePath = path.relative(options.rootPath, fullPath); - // Add trailing slash for directories for proper gitignore matching - const pathToCheck = entryType === "directory" ? relativePath + "/" : relativePath; - if (options.ig.ignores(pathToCheck)) { - continue; - } - } - - // For pattern matching: - // - If it's a file, check if it matches the pattern - // - If it's a directory, we'll add it provisionally and remove it later if it has no matches - let matchesPattern = true; - if (options.pattern && entryType === "file") { - matchesPattern = minimatch(dirent.name, options.pattern, { matchBase: true }); - } - - // Skip files that don't match pattern - if (entryType === "file" && !matchesPattern) { - continue; - } - - // Check limit before adding (even for directories we'll explore) - if (currentCount.value >= options.maxEntries) { - return { entries, totalCount: currentCount.value + 1, exceeded: true }; - } - - // Increment counter - currentCount.value++; - - const entry: FileEntry = { - name: dirent.name, - type: entryType, - }; - - // Get size for files - if (entryType === "file") { - try { - const stats = await fs.stat(fullPath); - entry.size = stats.size; - } catch { - // If we can't stat the file, skip size - } - } - - // Recurse into directories if within depth limit - if (entryType === "directory" && currentDepth < maxDepth) { - const result = await buildFileTree( - fullPath, - currentDepth + 1, - maxDepth, - options, - currentCount - ); - - if (result.exceeded) { - // Don't add this directory since we exceeded the limit while processing it - currentCount.value--; // Revert the increment for this directory - return { entries, totalCount: result.totalCount, exceeded: true }; - } - - entry.children = result.entries; - - // If we have a pattern and this directory has no matching descendants, skip it - if (options.pattern && entry.children.length === 0) { - currentCount.value--; // Revert the increment - continue; - } - } - - entries.push(entry); - } - - return { entries, totalCount: currentCount.value, exceeded: false }; -} - /** * Load and parse .gitignore file if it exists */ @@ -232,9 +101,142 @@ export function createFileListTool(config: { cwd: string }) { // Enforce entry limit const effectiveMaxEntries = Math.min(Math.max(1, max_entries), FILE_LIST_HARD_MAX_ENTRIES); - // Load .gitignore if requested + // Load .gitignore if requested (loaded once, used across entire traversal via closure) const ig = gitignore ? await loadGitignore(resolvedPath) : null; + /** + * Recursively build a file tree structure with depth control and filtering. + * Uses closure to access ig (ignore instance) and resolvedPath without passing them through recursion. + * Counts entries as they're added and stops when limit is reached. + * + * @param dir - Directory to traverse + * @param currentDepth - Current depth level (1 = immediate children) + * @param maxDepth - Maximum depth to traverse + * @param options - Filtering options (pattern, entry limit) + * @param currentCount - Shared counter tracking total entries across recursion + * @returns Tree structure with entries, total count, and exceeded flag + */ + async function buildFileTree( + dir: string, + currentDepth: number, + maxDepth: number, + options: TraversalOptions, + currentCount: { value: number } + ): Promise { + // Check if we've already exceeded the limit + if (currentCount.value >= options.maxEntries) { + return { entries: [], totalCount: currentCount.value, exceeded: true }; + } + + let dirents; + try { + dirents = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + // If we can't read the directory (permissions, etc.), skip it + return { entries: [], totalCount: currentCount.value, exceeded: false }; + } + + // Sort: directories first, then files, alphabetically within each group + dirents.sort((a, b) => { + const aIsDir = a.isDirectory(); + const bIsDir = b.isDirectory(); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.name.localeCompare(b.name); + }); + + const entries: FileEntry[] = []; + + for (const dirent of dirents) { + const fullPath = path.join(dir, dirent.name); + const entryType = dirent.isDirectory() + ? "directory" + : dirent.isFile() + ? "file" + : "symlink"; + + // Always skip .git directory regardless of gitignore setting + if (dirent.name === ".git" && entryType === "directory") { + continue; + } + + // Check gitignore filtering (uses ig from closure) + if (gitignore && ig) { + const relativePath = path.relative(resolvedPath, fullPath); + // Add trailing slash for directories for proper gitignore matching + const pathToCheck = entryType === "directory" ? relativePath + "/" : relativePath; + if (ig.ignores(pathToCheck)) { + continue; + } + } + + // For pattern matching: + // - If it's a file, check if it matches the pattern + // - If it's a directory, we'll add it provisionally and remove it later if it has no matches + let matchesPattern = true; + if (options.pattern && entryType === "file") { + matchesPattern = minimatch(dirent.name, options.pattern, { matchBase: true }); + } + + // Skip files that don't match pattern + if (entryType === "file" && !matchesPattern) { + continue; + } + + // Check limit before adding (even for directories we'll explore) + if (currentCount.value >= options.maxEntries) { + return { entries, totalCount: currentCount.value + 1, exceeded: true }; + } + + // Increment counter + currentCount.value++; + + const entry: FileEntry = { + name: dirent.name, + type: entryType, + }; + + // Get size for files + if (entryType === "file") { + try { + const stats = await fs.stat(fullPath); + entry.size = stats.size; + } catch { + // If we can't stat the file, skip size + } + } + + // Recurse into directories if within depth limit + if (entryType === "directory" && currentDepth < maxDepth) { + const result = await buildFileTree( + fullPath, + currentDepth + 1, + maxDepth, + options, + currentCount + ); + + if (result.exceeded) { + // Don't add this directory since we exceeded the limit while processing it + currentCount.value--; // Revert the increment for this directory + return { entries, totalCount: result.totalCount, exceeded: true }; + } + + entry.children = result.entries; + + // If we have a pattern and this directory has no matching descendants, skip it + if (options.pattern && entry.children.length === 0) { + currentCount.value--; // Revert the increment + continue; + } + } + + entries.push(entry); + } + + return { entries, totalCount: currentCount.value, exceeded: false }; + } + // Build the file tree const currentCount = { value: 0 }; const result = await buildFileTree( @@ -243,10 +245,7 @@ export function createFileListTool(config: { cwd: string }) { effectiveDepth, { pattern, - useGitignore: gitignore, maxEntries: effectiveMaxEntries, - ig, - rootPath: resolvedPath, }, currentCount ); From 98915bb0e2b9a6d8cc5bde218839e14d0194a105 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:49:36 -0500 Subject: [PATCH 05/16] Fix lint errors in file_list implementation - Remove unused FileListToolArgs import - Remove unused 'err' variables in catch blocks - Replace {} as any with proper AbortController signal in tests --- src/services/tools/file_list.test.ts | 36 ++++++++++++++-------------- src/services/tools/file_list.ts | 6 ++--- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index dddc6e169..1b9003570 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -31,7 +31,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -58,7 +58,7 @@ describe("file_list tool", () => { path: ".", max_depth: 2, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -80,7 +80,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -92,7 +92,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -112,7 +112,7 @@ describe("file_list tool", () => { path: ".", pattern: "*.ts", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -133,7 +133,7 @@ describe("file_list tool", () => { pattern: "*.ts", max_depth: 2, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -158,7 +158,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -179,7 +179,7 @@ describe("file_list tool", () => { path: ".", gitignore: false, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -196,7 +196,7 @@ describe("file_list tool", () => { path: ".", gitignore: false, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -212,7 +212,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -232,7 +232,7 @@ describe("file_list tool", () => { { path: ".", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(false); @@ -251,7 +251,7 @@ describe("file_list tool", () => { path: ".", max_entries: 10, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(false); @@ -268,7 +268,7 @@ describe("file_list tool", () => { path: ".", max_entries: 200, // Try to exceed hard cap }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -282,7 +282,7 @@ describe("file_list tool", () => { { path: "nonexistent", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(false); @@ -296,7 +296,7 @@ describe("file_list tool", () => { { path: "file.txt", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(false); @@ -308,7 +308,7 @@ describe("file_list tool", () => { { path: "..", }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(false); @@ -331,7 +331,7 @@ describe("file_list tool", () => { path: ".", max_depth: 15, // Try to exceed max }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); @@ -348,7 +348,7 @@ describe("file_list tool", () => { path: ".", max_depth: 1, }, - {} as any + { abortSignal: new AbortController().signal } )) as Extract; expect(result.success).toBe(true); diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 38eaff5d3..38cbca8a9 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { minimatch } from "minimatch"; import ignore from "ignore"; -import type { FileEntry, FileListToolArgs, FileListToolResult } from "@/types/tools"; +import type { FileEntry, FileListToolResult } from "@/types/tools"; import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; import { validatePathInCwd } from "./fileCommon"; import { @@ -81,7 +81,7 @@ export function createFileListTool(config: { cwd: string }) { let stats; try { stats = await fs.stat(resolvedPath); - } catch (err) { + } catch { return { success: false, error: `Path does not exist: ${targetPath}`, @@ -131,7 +131,7 @@ export function createFileListTool(config: { cwd: string }) { let dirents; try { dirents = await fs.readdir(dir, { withFileTypes: true }); - } catch (err) { + } catch { // If we can't read the directory (permissions, etc.), skip it return { entries: [], totalCount: currentCount.value, exceeded: false }; } From 863a0b271133be5c66eade89f815aab40ae91b6c Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:56:34 -0500 Subject: [PATCH 06/16] Convert file_list to output string format for token savings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return formatted tree string in 'output' field instead of recursive JSON 'entries' - Saves ~50% tokens for typical listings - Format function handles tree characters (├─, └─, │) and file sizes - Updated UI to display pre-formatted output string - Updated all tests to validate string output instead of structured data --- src/components/tools/FileListToolCall.tsx | 85 +++-------------------- src/services/tools/file_list.test.ts | 66 ++++++++++-------- src/services/tools/file_list.ts | 43 +++++++++++- src/types/tools.ts | 2 +- 4 files changed, 91 insertions(+), 105 deletions(-) diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index 154478798..95572e12f 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -1,7 +1,6 @@ import React from "react"; import styled from "@emotion/styled"; import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools"; -import { formatSize } from "@/services/tools/fileCommon"; import { ToolContainer, ToolHeader, @@ -52,42 +51,17 @@ const ErrorHint = styled.div` font-style: italic; `; -const TreeContainer = styled.div` - margin-top: 8px; +const OutputBlock = styled.pre` + margin: 0; + padding: 8px 12px; background: rgba(0, 0, 0, 0.2); border-radius: 3px; - padding: 12px; + font-size: 11px; + line-height: 1.6; + white-space: pre; overflow-x: auto; font-family: var(--font-monospace); - line-height: 1.6; -`; - -const Entry = styled.div` - display: flex; - align-items: center; - white-space: nowrap; - font-size: 11px; -`; - -const Prefix = styled.span` - color: var(--color-text-secondary); - user-select: none; -`; - -const Icon = styled.span` - margin-right: 6px; - user-select: none; -`; - -const Name = styled.span` color: var(--color-text); - font-weight: 500; -`; - -const Size = styled.span` - color: var(--color-text-secondary); - margin-left: 8px; - font-size: 10px; `; const EmptyMessage = styled.div` @@ -103,41 +77,6 @@ interface FileListToolCallProps { status: "pending" | "streaming" | "complete" | "error"; } -/** - * Recursively render a file tree with indentation - */ -function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] { - const elements: JSX.Element[] = []; - - entries.forEach((entry, index) => { - const isLast = index === entries.length - 1; - const prefix = depth === 0 ? "" : "│ ".repeat(depth - 1) + (isLast ? "└─ " : "├─ "); - - const icon = entry.type === "directory" ? "📁" : entry.type === "file" ? "📄" : "🔗"; - const suffix = entry.type === "directory" ? "/" : ""; - const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : ""; - - elements.push( - - {prefix} - {icon} - - {entry.name} - {suffix} - - {sizeInfo && {sizeInfo}} - - ); - - // Recursively render children if present - if (entry.children && entry.children.length > 0) { - elements.push(...renderFileTree(entry.children, depth + 1)); - } - }); - - return elements; -} - export const FileListToolCall: React.FC = ({ args, result, status }) => { const { expanded, toggleExpanded } = useToolExpansion(false); const isError = status === "error" || (result && !result.success); @@ -206,13 +145,11 @@ export const FileListToolCall: React.FC = ({ args, result {isComplete && result && result.success && ( Contents ({result.total_count} entries) - - {result.entries.length === 0 ? ( - Empty directory - ) : ( - <>{renderFileTree(result.entries)} - )} - + {result.output === "(empty directory)" ? ( + Empty directory + ) : ( + {result.output} + )} )} diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index 1b9003570..d32291b14 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -35,16 +35,16 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.length).toBe(3); expect(result.total_count).toBe(3); expect(result.depth_used).toBe(1); - // Check sorting: directories first - expect(result.entries[0].name).toBe("subdir"); - expect(result.entries[0].type).toBe("directory"); - expect(result.entries[1].name).toBe("file1.txt"); - expect(result.entries[1].type).toBe("file"); - expect(result.entries[2].name).toBe("file2.txt"); + // Check output contains expected entries + expect(result.output).toContain("subdir/"); + expect(result.output).toContain("file1.txt"); + expect(result.output).toContain("file2.txt"); + + // Check sorting: directories first (subdir appears before files in output) + expect(result.output.indexOf("subdir/")).toBeLessThan(result.output.indexOf("file1.txt")); }); test("lists files recursively (depth 2)", async () => { @@ -65,11 +65,13 @@ describe("file_list tool", () => { expect(result.total_count).toBe(3); // dir1, dir1/file1.txt, root.txt expect(result.depth_used).toBe(2); - const dir1 = result.entries.find((e) => e.name === "dir1"); - expect(dir1).toBeDefined(); - expect(dir1!.children).toBeDefined(); - expect(dir1!.children!.length).toBe(1); - expect(dir1!.children![0].name).toBe("file1.txt"); + // Check output shows nested structure + expect(result.output).toContain("dir1/"); + expect(result.output).toContain("file1.txt"); + expect(result.output).toContain("root.txt"); + + // Check indentation shows nesting (file1.txt should be indented under dir1) + expect(result.output).toMatch(/dir1\/\s*\n.*file1\.txt/); }); test("shows file sizes", async () => { @@ -84,7 +86,8 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries[0].size).toBe(100); + // Check output includes file size + expect(result.output).toMatch(/file\.txt.*\(100B\)/); }); test("empty directory", async () => { @@ -96,8 +99,8 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.length).toBe(0); expect(result.total_count).toBe(0); + expect(result.output).toBe("(empty directory)"); }); }); @@ -116,8 +119,10 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.length).toBe(2); - expect(result.entries.every((e) => e.name.endsWith(".ts"))).toBe(true); + expect(result.total_count).toBe(2); + expect(result.output).toContain("file1.ts"); + expect(result.output).toContain("file3.ts"); + expect(result.output).not.toContain("file2.js"); }); test("prunes empty directories when using pattern", async () => { @@ -138,8 +143,10 @@ describe("file_list tool", () => { expect(result.success).toBe(true); // Should only include hasTs directory (not noTs) - expect(result.entries.length).toBe(1); - expect(result.entries[0].name).toBe("hasTs"); + expect(result.total_count).toBe(2); // hasTs dir + file.ts inside + expect(result.output).toContain("hasTs/"); + expect(result.output).toContain("file.ts"); + expect(result.output).not.toContain("noTs"); }); }); @@ -163,10 +170,10 @@ describe("file_list tool", () => { expect(result.success).toBe(true); // Should include .gitignore and included.txt, but not ignored.txt or node_modules - expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true); - expect(result.entries.some((e) => e.name === "included.txt")).toBe(true); - expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(false); - expect(result.entries.some((e) => e.name === "node_modules")).toBe(false); + expect(result.output).toContain(".gitignore"); + expect(result.output).toContain("included.txt"); + expect(result.output).not.toContain("ignored.txt"); + expect(result.output).not.toContain("node_modules"); }); test("shows all files when gitignore=false", async () => { @@ -183,7 +190,7 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(true); + expect(result.output).toContain("ignored.txt"); }); test("always hides .git directory", async () => { @@ -200,7 +207,7 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.some((e) => e.name === ".git")).toBe(false); + expect(result.output).not.toContain(".git"); }); test("shows hidden files (dotfiles)", async () => { @@ -216,8 +223,8 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - expect(result.entries.some((e) => e.name === ".env")).toBe(true); - expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true); + expect(result.output).toContain(".env"); + expect(result.output).toContain(".gitignore"); }); }); @@ -352,9 +359,10 @@ describe("file_list tool", () => { )) as Extract; expect(result.success).toBe(true); - const dir = result.entries.find((e) => e.name === "dir1"); - expect(dir).toBeDefined(); - expect(dir!.children).toBeUndefined(); // No children at depth 1 + expect(result.output).toContain("dir1/"); + expect(result.output).toContain("root.txt"); + // At depth 1, nested.txt should NOT appear (not traversed) + expect(result.output).not.toContain("nested.txt"); }); }); }); diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 38cbca8a9..68607e4a5 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -24,6 +24,43 @@ interface TraversalResult { exceeded: boolean; } +/** + * Format a file tree as a string with tree characters (├─, └─, │) + * Recursively formats the tree structure for display to LLM + */ +function formatTreeAsString(entries: FileEntry[], indent = "", isLast: boolean[] = []): string { + const lines: string[] = []; + + entries.forEach((entry, i) => { + const isLastEntry = i === entries.length - 1; + const prefix = isLast.length > 0 ? indent + (isLastEntry ? "└─ " : "├─ ") : ""; + + const suffix = entry.type === "directory" ? "/" : ""; + const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : ""; + + lines.push(`${prefix}${entry.name}${suffix}${sizeInfo}`); + + // Recursively render children if present + if (entry.children && entry.children.length > 0) { + const newIndent = indent + (isLastEntry ? " " : "│ "); + lines.push( + ...formatTreeAsString(entry.children, newIndent, [...isLast, isLastEntry]).split("\n") + ); + } + }); + + return lines.join("\n"); +} + +/** + * Format a file size in bytes to a human-readable string + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + /** * Load and parse .gitignore file if it exists */ @@ -267,10 +304,14 @@ export function createFileListTool(config: { cwd: string }) { }; } + // Format tree as string for LLM (token efficient) + const output = + result.entries.length === 0 ? "(empty directory)" : formatTreeAsString(result.entries); + return { success: true, path: resolvedPath, - entries: result.entries, + output: output, total_count: result.totalCount, depth_used: effectiveDepth, }; diff --git a/src/types/tools.ts b/src/types/tools.ts index 6e2e36d8c..dfa93d193 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -176,7 +176,7 @@ export type FileListToolResult = | { success: true; path: string; // Resolved absolute path that was listed - entries: FileEntry[]; // Top-level entries (recursive structure) + output: string; // Formatted tree structure as string total_count: number; // Total entries across all levels depth_used: number; // Maximum depth traversed } From 3785bdda6fe0fa492e3b00994d3480a4557ff2e7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:57:37 -0500 Subject: [PATCH 07/16] Remove unused file_list UI routing - uses GenericToolCall fallback The file_list tool is already handled by GenericToolCall at the end of ToolMessage.tsx, so the custom FileListToolCall component and routing were never actually used. Removed to fix lint errors. --- src/components/Messages/ToolMessage.tsx | 9 ++------- src/components/tools/FileListToolCall.tsx | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index b39246859..18ff922a0 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -5,7 +5,7 @@ import { GenericToolCall } from "../tools/GenericToolCall"; import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; -import { FileListToolCall } from "../tools/FileListToolCall"; + import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import { TodoToolCall } from "../tools/TodoToolCall"; import type { @@ -13,8 +13,6 @@ import type { BashToolResult, FileReadToolArgs, FileReadToolResult, - FileListToolArgs, - FileListToolResult, FileEditInsertToolArgs, FileEditInsertToolResult, FileEditReplaceStringToolArgs, @@ -45,10 +43,7 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success; } -function isFileListTool(toolName: string, args: unknown): args is FileListToolArgs { - if (toolName !== "file_list") return false; - return TOOL_DEFINITIONS.file_list.schema.safeParse(args).success; -} + function isFileEditReplaceStringTool( toolName: string, diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index 95572e12f..0b995fc87 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -1,6 +1,6 @@ import React from "react"; import styled from "@emotion/styled"; -import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools"; +import type { FileListToolArgs, FileListToolResult } from "@/types/tools"; import { ToolContainer, ToolHeader, From 700e2df9fd0348191526795c1201f7dd7d87dc50 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:58:04 -0500 Subject: [PATCH 08/16] Fix test mock to include required ToolCallOptions fields --- src/services/tools/file_list.test.ts | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index d32291b14..37ca14526 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -31,7 +31,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -58,7 +58,7 @@ describe("file_list tool", () => { path: ".", max_depth: 2, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -82,7 +82,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -95,7 +95,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -115,7 +115,7 @@ describe("file_list tool", () => { path: ".", pattern: "*.ts", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -138,7 +138,7 @@ describe("file_list tool", () => { pattern: "*.ts", max_depth: 2, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -165,7 +165,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -186,7 +186,7 @@ describe("file_list tool", () => { path: ".", gitignore: false, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -203,7 +203,7 @@ describe("file_list tool", () => { path: ".", gitignore: false, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -219,7 +219,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -239,7 +239,7 @@ describe("file_list tool", () => { { path: ".", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(false); @@ -258,7 +258,7 @@ describe("file_list tool", () => { path: ".", max_entries: 10, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(false); @@ -275,7 +275,7 @@ describe("file_list tool", () => { path: ".", max_entries: 200, // Try to exceed hard cap }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -289,7 +289,7 @@ describe("file_list tool", () => { { path: "nonexistent", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(false); @@ -303,7 +303,7 @@ describe("file_list tool", () => { { path: "file.txt", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(false); @@ -315,7 +315,7 @@ describe("file_list tool", () => { { path: "..", }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(false); @@ -338,7 +338,7 @@ describe("file_list tool", () => { path: ".", max_depth: 15, // Try to exceed max }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); @@ -355,7 +355,7 @@ describe("file_list tool", () => { path: ".", max_depth: 1, }, - { abortSignal: new AbortController().signal } + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } )) as Extract; expect(result.success).toBe(true); From ff499ddba2216cad20e450ee448dcf0b462a5044 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:58:35 -0500 Subject: [PATCH 09/16] Fix lint errors - remove unused imports and fix test types --- src/components/Messages/ToolMessage.tsx | 2 ++ src/services/tools/file_list.test.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index 18ff922a0..8a44e97fe 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -13,6 +13,8 @@ import type { BashToolResult, FileReadToolArgs, FileReadToolResult, + FileListToolArgs, + FileListToolResult, FileEditInsertToolArgs, FileEditInsertToolResult, FileEditReplaceStringToolArgs, diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index 37ca14526..e3e374c64 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -3,7 +3,14 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; import { createFileListTool } from "./file_list"; -import type { FileListToolArgs, FileListToolResult } from "@/types/tools"; +import type { FileListToolResult } from "@/types/tools"; +import type { ToolCallOptions } from "ai"; + +// Mock ToolCallOptions for testing +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; describe("file_list tool", () => { let tempDir: string; From 954e18ac13712d16ad956baf999ce1d66bdb07a9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 20:58:54 -0500 Subject: [PATCH 10/16] Format files with prettier --- src/components/Messages/ToolMessage.tsx | 2 -- src/services/tools/file_list.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index 8a44e97fe..83620ef93 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -45,8 +45,6 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success; } - - function isFileEditReplaceStringTool( toolName: string, args: unknown diff --git a/src/services/tools/file_list.test.ts b/src/services/tools/file_list.test.ts index e3e374c64..aaa6c523b 100644 --- a/src/services/tools/file_list.test.ts +++ b/src/services/tools/file_list.test.ts @@ -49,7 +49,7 @@ describe("file_list tool", () => { expect(result.output).toContain("subdir/"); expect(result.output).toContain("file1.txt"); expect(result.output).toContain("file2.txt"); - + // Check sorting: directories first (subdir appears before files in output) expect(result.output.indexOf("subdir/")).toBeLessThan(result.output.indexOf("file1.txt")); }); @@ -76,7 +76,7 @@ describe("file_list tool", () => { expect(result.output).toContain("dir1/"); expect(result.output).toContain("file1.txt"); expect(result.output).toContain("root.txt"); - + // Check indentation shows nesting (file1.txt should be indented under dir1) expect(result.output).toMatch(/dir1\/\s*\n.*file1\.txt/); }); From 6fdb7dc28405f1b962d0948e0b58d9e917af48b3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:00:51 -0500 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=A4=96=20Wire=20up=20FileListToolCa?= =?UTF-8?q?ll=20component=20in=20ToolMessage=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add import for FileListToolCall component - Add isFileListTool type guard using Zod schema - Add routing case for file_list tool - Fix status type: use ToolStatus instead of custom status strings - Update status checks: 'completed'/'failed'/'executing' instead of 'complete'/'error'/'streaming' Fixes component routing so file_list tool calls render correctly in UI. --- src/components/Messages/ToolMessage.tsx | 18 ++++++++++++++++++ src/components/tools/FileListToolCall.tsx | 10 +++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index 83620ef93..73a4e8b4b 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -5,6 +5,7 @@ import { GenericToolCall } from "../tools/GenericToolCall"; import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; +import { FileListToolCall } from "../tools/FileListToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import { TodoToolCall } from "../tools/TodoToolCall"; @@ -45,6 +46,11 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success; } +function isFileListTool(toolName: string, args: unknown): args is FileListToolArgs { + if (toolName !== "file_list") return false; + return TOOL_DEFINITIONS.file_list.schema.safeParse(args).success; +} + function isFileEditReplaceStringTool( toolName: string, args: unknown @@ -103,6 +109,18 @@ export const ToolMessage: React.FC = ({ message, className, wo ); } + if (isFileListTool(message.toolName, message.args)) { + return ( +
+ +
+ ); + } + if (isFileEditReplaceStringTool(message.toolName, message.args)) { return (
diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index 0b995fc87..4b0fd0931 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -11,7 +11,7 @@ import { DetailLabel, LoadingDots, } from "./shared/ToolPrimitives"; -import { useToolExpansion, getStatusDisplay } from "./shared/toolUtils"; +import { useToolExpansion, getStatusDisplay, ToolStatus } from "./shared/toolUtils"; // FileList-specific styled components @@ -74,14 +74,14 @@ const EmptyMessage = styled.div` interface FileListToolCallProps { args: FileListToolArgs; result?: FileListToolResult; - status: "pending" | "streaming" | "complete" | "error"; + status: ToolStatus; } export const FileListToolCall: React.FC = ({ args, result, status }) => { const { expanded, toggleExpanded } = useToolExpansion(false); - const isError = status === "error" || (result && !result.success); - const isComplete = status === "complete"; - const isPending = status === "pending" || status === "streaming"; + const isError = status === "failed" || (result && !result.success); + const isComplete = status === "completed"; + const isPending = status === "pending" || status === "executing"; // Build parameter summary const params: string[] = []; From 5761d53c29f9e3030b52003e763688e6d7f9ea97 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:05:00 -0500 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=A4=96=20Create=20shared=20ToolIcon?= =?UTF-8?q?=20component=20for=20consistent=20emoji=20+=20tooltip=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ToolIcon component in shared/ToolIcon.tsx - Update all tool components to use ToolIcon (except GenericToolCall) - BashToolCall: 🔧 bash - FileReadToolCall: 📖 file_read - FileListToolCall: 📖 file_list (changed from 📋) - FileEditToolCall: ✏️ file_edit_* - TodoToolCall: 📋 todo_write - ProposePlanToolCall: 📋 propose_plan (added tooltip) - GenericToolCall remains unchanged (text-only fallback) - Consistent tooltip pattern: hover over emoji shows full tool name This abstracts the emoji+tooltip pattern into a single reusable component, ensures consistency across all specialized tool displays, and unifies the file_read and file_list emojis to 📖. --- src/components/tools/BashToolCall.tsx | 7 ++----- src/components/tools/FileEditToolCall.tsx | 7 ++----- src/components/tools/FileListToolCall.tsx | 3 ++- src/components/tools/FileReadToolCall.tsx | 7 ++----- src/components/tools/ProposePlanToolCall.tsx | 3 ++- src/components/tools/TodoToolCall.tsx | 7 ++----- src/components/tools/shared/ToolIcon.tsx | 21 ++++++++++++++++++++ 7 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 src/components/tools/shared/ToolIcon.tsx diff --git a/src/components/tools/BashToolCall.tsx b/src/components/tools/BashToolCall.tsx index 6953a765e..e9750f9cd 100644 --- a/src/components/tools/BashToolCall.tsx +++ b/src/components/tools/BashToolCall.tsx @@ -14,7 +14,7 @@ import { LoadingDots, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { ToolIcon } from "./shared/ToolIcon"; // Bash-specific styled components @@ -123,10 +123,7 @@ export const BashToolCall: React.FC = ({ - - 🔧 - bash - + {args.script} timeout: {args.timeout_secs ?? BASH_DEFAULT_TIMEOUT_SECS}s diff --git a/src/components/tools/FileEditToolCall.tsx b/src/components/tools/FileEditToolCall.tsx index e816c45bb..a32085fa7 100644 --- a/src/components/tools/FileEditToolCall.tsx +++ b/src/components/tools/FileEditToolCall.tsx @@ -21,7 +21,7 @@ import { HeaderButton, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { ToolIcon } from "./shared/ToolIcon"; // File edit specific styled components @@ -290,10 +290,7 @@ export const FileEditToolCall: React.FC = ({ - - ✏️ - {toolName} - + {filePath} {!(result && result.success && result.diff) && ( diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index 4b0fd0931..bd091cc68 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -12,6 +12,7 @@ import { LoadingDots, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, ToolStatus } from "./shared/toolUtils"; +import { ToolIcon } from "./shared/ToolIcon"; // FileList-specific styled components @@ -107,7 +108,7 @@ export const FileListToolCall: React.FC = ({ args, result - 📋 file_list + {args.path} {paramStr && {paramStr}} {isComplete && result && result.success && ( diff --git a/src/components/tools/FileReadToolCall.tsx b/src/components/tools/FileReadToolCall.tsx index 53be4ce1e..2c0f6f864 100644 --- a/src/components/tools/FileReadToolCall.tsx +++ b/src/components/tools/FileReadToolCall.tsx @@ -13,7 +13,7 @@ import { LoadingDots, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { ToolIcon } from "./shared/ToolIcon"; // FileRead-specific styled components @@ -165,10 +165,7 @@ export const FileReadToolCall: React.FC = ({ - - 📖 - file_read - + {filePath} {result && result.success && parsedContent && ( diff --git a/src/components/tools/ProposePlanToolCall.tsx b/src/components/tools/ProposePlanToolCall.tsx index 57b3fc89a..c5bc2ef20 100644 --- a/src/components/tools/ProposePlanToolCall.tsx +++ b/src/components/tools/ProposePlanToolCall.tsx @@ -10,6 +10,7 @@ import { ToolDetails, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; +import { ToolIcon } from "./shared/ToolIcon"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { useStartHere } from "@/hooks/useStartHere"; @@ -285,7 +286,7 @@ export const ProposePlanToolCall: React.FC = ({ - propose_plan + {statusDisplay} diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 68404e829..4526ccd70 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -8,7 +8,7 @@ import { ToolDetails, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { ToolIcon } from "./shared/ToolIcon"; import { TodoList } from "../TodoList"; interface TodoToolCallProps { @@ -29,10 +29,7 @@ export const TodoToolCall: React.FC = ({ - - 📋 - todo_write - + {statusDisplay} diff --git a/src/components/tools/shared/ToolIcon.tsx b/src/components/tools/shared/ToolIcon.tsx new file mode 100644 index 000000000..799b31f97 --- /dev/null +++ b/src/components/tools/shared/ToolIcon.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { TooltipWrapper, Tooltip } from "../../Tooltip"; + +interface ToolIconProps { + emoji: string; + toolName: string; +} + +/** + * Shared component for displaying tool emoji with tooltip showing the full tool name. + * Used consistently across all tool components in ToolHeader. + */ +export const ToolIcon: React.FC = ({ emoji, toolName }) => { + return ( + + {emoji} + {toolName} + + ); +}; + From f7ae61c86d0266b27e5c65c6d2bccf3507055721 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:06:06 -0500 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=A4=96=20Ensure=20file=5Flist=20dis?= =?UTF-8?q?plays=20directory=20paths=20with=20trailing=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add trailing slash to displayed path in FileListToolCall to clearly indicate it's a directory being listed. Only affects display - the actual tool arg remains unchanged. --- src/components/tools/FileListToolCall.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/tools/FileListToolCall.tsx b/src/components/tools/FileListToolCall.tsx index bd091cc68..49b0ce431 100644 --- a/src/components/tools/FileListToolCall.tsx +++ b/src/components/tools/FileListToolCall.tsx @@ -101,6 +101,9 @@ export const FileListToolCall: React.FC = ({ args, result const paramStr = params.length > 0 ? `(${params.join(", ")})` : ""; + // Ensure path ends with / to indicate it's a directory + const displayPath = args.path.endsWith("/") ? args.path : `${args.path}/`; + // Convert our status to shared ToolStatus type const toolStatus = isError ? "failed" : isPending ? "executing" : "completed"; @@ -109,7 +112,7 @@ export const FileListToolCall: React.FC = ({ args, result - {args.path} + {displayPath} {paramStr && {paramStr}} {isComplete && result && result.success && ( {result.total_count} entries From 5b073cde4d4bc8479c8117aebb217ee064e45865 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:07:49 -0500 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=A4=96=20Remove=20decimal=20precisi?= =?UTF-8?q?on=20from=20file=20sizes=20to=20preserve=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change formatSize() to use Math.round() instead of toFixed(1): - Before: 1.5KB, 2.3MB - After: 2KB, 2MB No decimals in file sizes preserves tokens in LLM output without sacrificing meaningful precision for directory listings. Tests still pass as they use regex patterns matching both formats. --- src/services/tools/file_list.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 68607e4a5..704c11ea1 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -54,11 +54,12 @@ function formatTreeAsString(entries: FileEntry[], indent = "", isLast: boolean[] /** * Format a file size in bytes to a human-readable string + * No decimals to preserve tokens in LLM output */ function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + return `${Math.round(bytes / (1024 * 1024))}MB`; } /** From b36ff6ced6bc392e2a78f4c43d29c1375e806137 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:09:37 -0500 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=A4=96=20Use=20iterative=20fs.opend?= =?UTF-8?q?ir=20for=20memory=20efficiency=20and=20early=20termination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fs.readdir() with fs.opendir() async iterator: - More memory efficient: doesn't allocate full array for large directories - Early termination: stops reading if we collect 2x the limit (accounts for filtering) - Proper cleanup: uses finally block to ensure dirHandle.close() For huge directories (1000+ files), this prevents unnecessary memory allocation when we only need the first 64 entries. The 2x multiplier ensures we read enough entries to account for gitignore filtering and pattern matching. --- src/services/tools/file_list.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index 704c11ea1..f5cf9e0fe 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -166,12 +166,31 @@ export function createFileListTool(config: { cwd: string }) { return { entries: [], totalCount: currentCount.value, exceeded: true }; } - let dirents; + // Use opendir for iterative reading - more memory efficient and allows early termination + let dirHandle; + const dirents = []; try { - dirents = await fs.readdir(dir, { withFileTypes: true }); + dirHandle = await fs.opendir(dir); + + // Read directory entries iteratively to avoid allocating large arrays + // and to allow early termination if we reach the limit + for await (const dirent of dirHandle) { + dirents.push(dirent); + + // Early termination: stop reading if we've collected enough entries + // (accounts for filtering, so we read a bit more than the limit) + if (dirents.length > options.maxEntries * 2) { + break; + } + } } catch { // If we can't read the directory (permissions, etc.), skip it return { entries: [], totalCount: currentCount.value, exceeded: false }; + } finally { + // Always close the directory handle + if (dirHandle) { + await dirHandle.close(); + } } // Sort: directories first, then files, alphabetically within each group From e7d7327b22bb140225917cd19de852923ceb1758 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 21:14:43 -0500 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=A4=96=20Fix=20double-close=20error?= =?UTF-8?q?=20by=20using=20'using'=20pattern=20for=20directory=20handles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace try/finally with 'using' declaration for automatic resource cleanup. This prevents ERR_DIR_CLOSED errors when the async iterator auto-closes the directory handle and then finally block tries to close it again. Key changes: - Wrap Dir in AsyncDisposable object with Symbol.asyncDispose - Use Promise.resolve() to handle Bun's synchronous close() behavior - Properly catch errors if handle is already closed - Tests verify correct cleanup in all scenarios (early break, full iteration) The 'using' pattern ensures cleanup happens at scope exit, even with early returns or exceptions, while gracefully handling double-close. --- src/services/tools/file_list.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/services/tools/file_list.ts b/src/services/tools/file_list.ts index f5cf9e0fe..533e8d210 100644 --- a/src/services/tools/file_list.ts +++ b/src/services/tools/file_list.ts @@ -166,17 +166,28 @@ export function createFileListTool(config: { cwd: string }) { return { entries: [], totalCount: currentCount.value, exceeded: true }; } - // Use opendir for iterative reading - more memory efficient and allows early termination - let dirHandle; const dirents = []; try { - dirHandle = await fs.opendir(dir); - + const dirObj = await fs.opendir(dir); + // Use opendir for iterative reading - more memory efficient and allows early termination + // Wrap in AsyncDisposable for automatic cleanup via 'using' + await using dirHandle = { + dir: dirObj, + async [Symbol.asyncDispose]() { + try { + // close() may return void or Promise depending on runtime + await Promise.resolve(dirObj.close()); + } catch { + // Ignore errors if already closed (e.g., by iterator completion) + } + }, + }; + // Read directory entries iteratively to avoid allocating large arrays // and to allow early termination if we reach the limit - for await (const dirent of dirHandle) { + for await (const dirent of dirHandle.dir) { dirents.push(dirent); - + // Early termination: stop reading if we've collected enough entries // (accounts for filtering, so we read a bit more than the limit) if (dirents.length > options.maxEntries * 2) { @@ -186,11 +197,6 @@ export function createFileListTool(config: { cwd: string }) { } catch { // If we can't read the directory (permissions, etc.), skip it return { entries: [], totalCount: currentCount.value, exceeded: false }; - } finally { - // Always close the directory handle - if (dirHandle) { - await dirHandle.close(); - } } // Sort: directories first, then files, alphabetically within each group