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..73a4e8b4b 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -5,6 +5,8 @@ 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 +14,8 @@ import type { BashToolResult, FileReadToolArgs, FileReadToolResult, + FileListToolArgs, + FileListToolResult, FileEditInsertToolArgs, FileEditInsertToolResult, FileEditReplaceStringToolArgs, @@ -42,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 @@ -100,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/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 new file mode 100644 index 000000000..49b0ce431 --- /dev/null +++ b/src/components/tools/FileListToolCall.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { FileListToolArgs, FileListToolResult } from "@/types/tools"; +import { + ToolContainer, + ToolHeader, + ExpandIcon, + StatusIndicator, + ToolDetails, + DetailSection, + DetailLabel, + LoadingDots, +} from "./shared/ToolPrimitives"; +import { useToolExpansion, getStatusDisplay, ToolStatus } from "./shared/toolUtils"; +import { ToolIcon } from "./shared/ToolIcon"; + +// 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 OutputBlock = styled.pre` + margin: 0; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + font-size: 11px; + line-height: 1.6; + white-space: pre; + overflow-x: auto; + font-family: var(--font-monospace); + color: var(--color-text); +`; + +const EmptyMessage = styled.div` + color: var(--color-text-secondary); + font-style: italic; + text-align: center; + padding: 16px; +`; + +interface FileListToolCallProps { + args: FileListToolArgs; + result?: FileListToolResult; + status: ToolStatus; +} + +export const FileListToolCall: React.FC = ({ args, result, status }) => { + const { expanded, toggleExpanded } = useToolExpansion(false); + const isError = status === "failed" || (result && !result.success); + const isComplete = status === "completed"; + const isPending = status === "pending" || status === "executing"; + + // 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(", ")})` : ""; + + // 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"; + + return ( + + + + + {displayPath} + {paramStr && {paramStr}} + {isComplete && result && result.success && ( + {result.total_count} entries + )} + {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 state */} + {isComplete && result && result.success && ( + + Contents ({result.total_count} entries) + {result.output === "(empty directory)" ? ( + Empty directory + ) : ( + {result.output} + )} + + )} + + )} + + ); +}; 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} + + ); +}; + diff --git a/src/constants/toolLimits.ts b/src/constants/toolLimits.ts index 4c4089243..ef9304358 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 = 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/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..aaa6c523b --- /dev/null +++ b/src/services/tools/file_list.test.ts @@ -0,0 +1,375 @@ +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 { 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; + 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: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + expect(result.total_count).toBe(3); + expect(result.depth_used).toBe(1); + + // 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 () => { + // 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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); + + // 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 () => { + const content = "a".repeat(100); + await fs.writeFile(path.join(tempDir, "file.txt"), content); + + const result = (await tool.execute!( + { + path: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + // Check output includes file size + expect(result.output).toMatch(/file\.txt.*\(100B\)/); + }); + + test("empty directory", async () => { + const result = (await tool.execute!( + { + path: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + expect(result.total_count).toBe(0); + expect(result.output).toBe("(empty directory)"); + }); + }); + + 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", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).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 () => { + // 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + // Should only include hasTs directory (not noTs) + 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"); + }); + }); + + 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: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + // Should include .gitignore and included.txt, but not ignored.txt or node_modules + 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 () => { + 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + expect(result.output).toContain("ignored.txt"); + }); + + 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + expect(result.output).not.toContain(".git"); + }); + + 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: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + expect(result.output).toContain(".env"); + expect(result.output).toContain(".gitignore"); + }); + }); + + describe("limit enforcement", () => { + test("returns error when exceeding default limit", async () => { + // 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}`); + } + + const result = (await tool.execute!( + { + path: ".", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(false); + expect(result.error).toContain("exceed limit"); + expect(result.total_found).toBeGreaterThan(64); + expect(result.limit_requested).toBe(64); + }); + + 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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 + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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: "..", + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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 + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) 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, + }, + { abortSignal: new AbortController().signal, toolCallId: "test", messages: [] } + )) as Extract; + + expect(result.success).toBe(true); + 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 new file mode 100644 index 000000000..533e8d210 --- /dev/null +++ b/src/services/tools/file_list.ts @@ -0,0 +1,346 @@ +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, 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; + maxEntries: number; +} + +interface TraversalResult { + entries: FileEntry[]; + totalCount: number; + 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 + * No decimals to preserve tokens in LLM output + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + return `${Math.round(bytes / (1024 * 1024))}MB`; +} + +/** + * 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 { + 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 (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 }; + } + + const dirents = []; + try { + 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.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) { + break; + } + } + } catch { + // 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( + resolvedPath, + 1, + effectiveDepth, + { + pattern, + maxEntries: effectiveMaxEntries, + }, + 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, + }; + } + + // Format tree as string for LLM (token efficient) + const output = + result.entries.length === 0 ? "(empty directory)" : formatTreeAsString(result.entries); + + return { + success: true, + path: resolvedPath, + output: output, + total_count: result.totalCount, + depth_used: effectiveDepth, + }; + }, + }); +} diff --git a/src/types/tools.ts b/src/types/tools.ts index 0173acb4b..dfa93d193 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 + output: string; // Formatted tree structure as string + 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