Skip to content

Commit ed6782d

Browse files
committed
🤖 Add file_list tool with recursive structure and gitignore support
- 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
1 parent 79a124f commit ed6782d

File tree

12 files changed

+1017
-1
lines changed

12 files changed

+1017
-1
lines changed

bun.lock

Lines changed: 44 additions & 1 deletion
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@
4343
"crc-32": "^1.2.2",
4444
"diff": "^8.0.2",
4545
"disposablestack": "^1.1.7",
46+
"ignore": "^7.0.5",
4647
"jsonc-parser": "^3.3.1",
4748
"lru-cache": "^11.2.2",
4849
"markdown-it": "^14.1.0",
4950
"mermaid": "^11.12.0",
51+
"minimatch": "^10.0.3",
5052
"minimist": "^1.2.8",
5153
"react": "^18.2.0",
5254
"react-dnd": "^16.0.1",
@@ -81,6 +83,7 @@
8183
"@types/jest": "^30.0.0",
8284
"@types/katex": "^0.16.7",
8385
"@types/markdown-it": "^14.1.2",
86+
"@types/minimatch": "^6.0.0",
8487
"@types/minimist": "^1.2.5",
8588
"@types/react": "^18.2.0",
8689
"@types/react-dom": "^18.2.0",

src/components/Messages/ToolMessage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { GenericToolCall } from "../tools/GenericToolCall";
55
import { BashToolCall } from "../tools/BashToolCall";
66
import { FileEditToolCall } from "../tools/FileEditToolCall";
77
import { FileReadToolCall } from "../tools/FileReadToolCall";
8+
import { FileListToolCall } from "../tools/FileListToolCall";
89
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
910
import { TodoToolCall } from "../tools/TodoToolCall";
1011
import type {
1112
BashToolArgs,
1213
BashToolResult,
1314
FileReadToolArgs,
1415
FileReadToolResult,
16+
FileListToolArgs,
17+
FileListToolResult,
1518
FileEditInsertToolArgs,
1619
FileEditInsertToolResult,
1720
FileEditReplaceStringToolArgs,
@@ -42,6 +45,11 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr
4245
return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success;
4346
}
4447

48+
function isFileListTool(toolName: string, args: unknown): args is FileListToolArgs {
49+
if (toolName !== "file_list") return false;
50+
return TOOL_DEFINITIONS.file_list.schema.safeParse(args).success;
51+
}
52+
4553
function isFileEditReplaceStringTool(
4654
toolName: string,
4755
args: unknown
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
.container {
2+
font-family: var(--font-mono);
3+
font-size: 13px;
4+
border-radius: 6px;
5+
background: var(--color-surface-elevated);
6+
padding: 12px;
7+
margin: 8px 0;
8+
}
9+
10+
.container.error {
11+
border-left: 3px solid var(--color-error);
12+
}
13+
14+
.header {
15+
display: flex;
16+
align-items: center;
17+
gap: 8px;
18+
margin-bottom: 8px;
19+
flex-wrap: wrap;
20+
}
21+
22+
.toolName {
23+
font-weight: 600;
24+
color: var(--color-text-primary);
25+
}
26+
27+
.path {
28+
color: var(--color-text-secondary);
29+
font-weight: 500;
30+
}
31+
32+
.params {
33+
color: var(--color-text-tertiary);
34+
font-size: 12px;
35+
}
36+
37+
.count {
38+
color: var(--color-text-tertiary);
39+
font-size: 12px;
40+
margin-left: auto;
41+
}
42+
43+
.status {
44+
color: var(--color-text-tertiary);
45+
padding: 8px;
46+
font-style: italic;
47+
}
48+
49+
/* Error styling */
50+
.errorMessage {
51+
background: var(--color-surface);
52+
border-radius: 4px;
53+
padding: 12px;
54+
margin-top: 8px;
55+
}
56+
57+
.errorTitle {
58+
font-weight: 600;
59+
color: var(--color-error);
60+
margin-bottom: 6px;
61+
}
62+
63+
.errorText {
64+
color: var(--color-text-secondary);
65+
line-height: 1.5;
66+
white-space: pre-wrap;
67+
}
68+
69+
.errorHint {
70+
color: var(--color-text-tertiary);
71+
font-size: 12px;
72+
margin-top: 8px;
73+
font-style: italic;
74+
}
75+
76+
/* Tree styling */
77+
.treeContainer {
78+
margin-top: 8px;
79+
background: var(--color-surface);
80+
border-radius: 4px;
81+
padding: 12px;
82+
overflow-x: auto;
83+
}
84+
85+
.tree {
86+
font-family: var(--font-mono);
87+
line-height: 1.6;
88+
}
89+
90+
.entry {
91+
display: flex;
92+
align-items: center;
93+
white-space: nowrap;
94+
}
95+
96+
.prefix {
97+
color: var(--color-text-tertiary);
98+
user-select: none;
99+
}
100+
101+
.icon {
102+
margin-right: 6px;
103+
user-select: none;
104+
}
105+
106+
.name {
107+
color: var(--color-text-primary);
108+
font-weight: 500;
109+
}
110+
111+
.size {
112+
color: var(--color-text-tertiary);
113+
margin-left: 8px;
114+
font-size: 12px;
115+
}
116+
117+
.empty {
118+
color: var(--color-text-tertiary);
119+
font-style: italic;
120+
text-align: center;
121+
padding: 16px;
122+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from "react";
2+
import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools";
3+
import { formatSize } from "@/services/tools/fileCommon";
4+
import styles from "./FileListToolCall.module.css";
5+
6+
interface FileListToolCallProps {
7+
args: FileListToolArgs;
8+
result?: FileListToolResult;
9+
status: "pending" | "streaming" | "complete" | "error";
10+
}
11+
12+
/**
13+
* Recursively render a file tree with indentation
14+
*/
15+
function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] {
16+
const elements: JSX.Element[] = [];
17+
18+
entries.forEach((entry, index) => {
19+
const isLast = index === entries.length - 1;
20+
const prefix = depth === 0 ? "" : "│ ".repeat(depth - 1) + (isLast ? "└─ " : "├─ ");
21+
22+
const icon = entry.type === "directory" ? "📁" : entry.type === "file" ? "📄" : "🔗";
23+
const suffix = entry.type === "directory" ? "/" : "";
24+
const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : "";
25+
26+
elements.push(
27+
<div key={`${depth}-${index}-${entry.name}`} className={styles.entry}>
28+
<span className={styles.prefix}>{prefix}</span>
29+
<span className={styles.icon}>{icon}</span>
30+
<span className={styles.name}>
31+
{entry.name}
32+
{suffix}
33+
</span>
34+
{sizeInfo && <span className={styles.size}>{sizeInfo}</span>}
35+
</div>
36+
);
37+
38+
// Recursively render children if present
39+
if (entry.children && entry.children.length > 0) {
40+
elements.push(...renderFileTree(entry.children, depth + 1));
41+
}
42+
});
43+
44+
return elements;
45+
}
46+
47+
export function FileListToolCall({ args, result, status }: FileListToolCallProps): JSX.Element {
48+
const isError = status === "error" || (result && !result.success);
49+
const isComplete = status === "complete";
50+
const isPending = status === "pending" || status === "streaming";
51+
52+
// Build parameter summary
53+
const params: string[] = [];
54+
if (args.max_depth !== undefined && args.max_depth !== 1) {
55+
params.push(`depth: ${args.max_depth}`);
56+
}
57+
if (args.pattern) {
58+
params.push(`pattern: ${args.pattern}`);
59+
}
60+
if (args.gitignore === false) {
61+
params.push("gitignore: off");
62+
}
63+
if (args.max_entries) {
64+
params.push(`max: ${args.max_entries}`);
65+
}
66+
67+
const paramStr = params.length > 0 ? ` (${params.join(", ")})` : "";
68+
69+
return (
70+
<div className={`${styles.container} ${isError ? styles.error : ""}`}>
71+
{/* Header */}
72+
<div className={styles.header}>
73+
<span className={styles.toolName}>📋 file_list:</span>
74+
<span className={styles.path}>{args.path}</span>
75+
<span className={styles.params}>{paramStr}</span>
76+
{isComplete && result && result.success && (
77+
<span className={styles.count}>{result.total_count} entries</span>
78+
)}
79+
</div>
80+
81+
{/* Status */}
82+
{isPending && <div className={styles.status}>⏳ Listing directory...</div>}
83+
84+
{/* Error */}
85+
{isError && result && !result.success && (
86+
<div className={styles.errorMessage}>
87+
<div className={styles.errorTitle}>❌ Error</div>
88+
<div className={styles.errorText}>{result.error}</div>
89+
{result.total_found !== undefined && (
90+
<div className={styles.errorHint}>
91+
Found {result.total_found}+ entries (limit: {result.limit_requested})
92+
</div>
93+
)}
94+
</div>
95+
)}
96+
97+
{/* Success - Render tree */}
98+
{isComplete && result && result.success && (
99+
<div className={styles.treeContainer}>
100+
{result.entries.length === 0 ? (
101+
<div className={styles.empty}>Empty directory</div>
102+
) : (
103+
<div className={styles.tree}>{renderFileTree(result.entries)}</div>
104+
)}
105+
</div>
106+
)}
107+
</div>
108+
);
109+
}

src/constants/toolLimits.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ export const BASH_HARD_MAX_LINES = 300;
44
export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line
55
export const BASH_MAX_TOTAL_BYTES = 16 * 1024; // 16KB total output
66

7+
export const FILE_LIST_DEFAULT_DEPTH = 1; // Non-recursive by default
8+
export const FILE_LIST_MAX_DEPTH = 10; // Allow deep traversal when needed
9+
export const FILE_LIST_DEFAULT_MAX_ENTRIES = 100; // Reasonable default
10+
export const FILE_LIST_HARD_MAX_ENTRIES = 128; // Absolute limit (prevent context overload)
11+
712
export const MAX_TODOS = 7; // Maximum number of TODO items in a list

src/services/tools/fileCommon.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,16 @@ export function validatePathInCwd(filePath: string, cwd: string): { error: strin
8888

8989
return null;
9090
}
91+
92+
/**
93+
* Format a file size in bytes to a human-readable string.
94+
* Uses KB for sizes >= 1KB, MB for sizes >= 1MB, otherwise bytes.
95+
*
96+
* @param bytes - File size in bytes
97+
* @returns Formatted size string (e.g., "1.5KB", "2.3MB", "512B")
98+
*/
99+
export function formatSize(bytes: number): string {
100+
if (bytes < 1024) return `${bytes}B`;
101+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
102+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
103+
}

0 commit comments

Comments
 (0)