Skip to content

Commit 14fb291

Browse files
committed
Convert file_list to output string format for token savings
- 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
1 parent 7878596 commit 14fb291

File tree

4 files changed

+91
-105
lines changed

4 files changed

+91
-105
lines changed

src/components/tools/FileListToolCall.tsx

Lines changed: 11 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from "react";
22
import styled from "@emotion/styled";
33
import type { FileListToolArgs, FileListToolResult, FileEntry } from "@/types/tools";
4-
import { formatSize } from "@/services/tools/fileCommon";
54
import {
65
ToolContainer,
76
ToolHeader,
@@ -52,42 +51,17 @@ const ErrorHint = styled.div`
5251
font-style: italic;
5352
`;
5453

55-
const TreeContainer = styled.div`
56-
margin-top: 8px;
54+
const OutputBlock = styled.pre`
55+
margin: 0;
56+
padding: 8px 12px;
5757
background: rgba(0, 0, 0, 0.2);
5858
border-radius: 3px;
59-
padding: 12px;
59+
font-size: 11px;
60+
line-height: 1.6;
61+
white-space: pre;
6062
overflow-x: auto;
6163
font-family: var(--font-monospace);
62-
line-height: 1.6;
63-
`;
64-
65-
const Entry = styled.div`
66-
display: flex;
67-
align-items: center;
68-
white-space: nowrap;
69-
font-size: 11px;
70-
`;
71-
72-
const Prefix = styled.span`
73-
color: var(--color-text-secondary);
74-
user-select: none;
75-
`;
76-
77-
const Icon = styled.span`
78-
margin-right: 6px;
79-
user-select: none;
80-
`;
81-
82-
const Name = styled.span`
8364
color: var(--color-text);
84-
font-weight: 500;
85-
`;
86-
87-
const Size = styled.span`
88-
color: var(--color-text-secondary);
89-
margin-left: 8px;
90-
font-size: 10px;
9165
`;
9266

9367
const EmptyMessage = styled.div`
@@ -103,41 +77,6 @@ interface FileListToolCallProps {
10377
status: "pending" | "streaming" | "complete" | "error";
10478
}
10579

106-
/**
107-
* Recursively render a file tree with indentation
108-
*/
109-
function renderFileTree(entries: FileEntry[], depth: number = 0): JSX.Element[] {
110-
const elements: JSX.Element[] = [];
111-
112-
entries.forEach((entry, index) => {
113-
const isLast = index === entries.length - 1;
114-
const prefix = depth === 0 ? "" : "│ ".repeat(depth - 1) + (isLast ? "└─ " : "├─ ");
115-
116-
const icon = entry.type === "directory" ? "📁" : entry.type === "file" ? "📄" : "🔗";
117-
const suffix = entry.type === "directory" ? "/" : "";
118-
const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : "";
119-
120-
elements.push(
121-
<Entry key={`${depth}-${index}-${entry.name}`}>
122-
<Prefix>{prefix}</Prefix>
123-
<Icon>{icon}</Icon>
124-
<Name>
125-
{entry.name}
126-
{suffix}
127-
</Name>
128-
{sizeInfo && <Size>{sizeInfo}</Size>}
129-
</Entry>
130-
);
131-
132-
// Recursively render children if present
133-
if (entry.children && entry.children.length > 0) {
134-
elements.push(...renderFileTree(entry.children, depth + 1));
135-
}
136-
});
137-
138-
return elements;
139-
}
140-
14180
export const FileListToolCall: React.FC<FileListToolCallProps> = ({ args, result, status }) => {
14281
const { expanded, toggleExpanded } = useToolExpansion(false);
14382
const isError = status === "error" || (result && !result.success);
@@ -206,13 +145,11 @@ export const FileListToolCall: React.FC<FileListToolCallProps> = ({ args, result
206145
{isComplete && result && result.success && (
207146
<DetailSection>
208147
<DetailLabel>Contents ({result.total_count} entries)</DetailLabel>
209-
<TreeContainer>
210-
{result.entries.length === 0 ? (
211-
<EmptyMessage>Empty directory</EmptyMessage>
212-
) : (
213-
<>{renderFileTree(result.entries)}</>
214-
)}
215-
</TreeContainer>
148+
{result.output === "(empty directory)" ? (
149+
<EmptyMessage>Empty directory</EmptyMessage>
150+
) : (
151+
<OutputBlock>{result.output}</OutputBlock>
152+
)}
216153
</DetailSection>
217154
)}
218155
</ToolDetails>

src/services/tools/file_list.test.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ describe("file_list tool", () => {
3535
)) as Extract<FileListToolResult, { success: true }>;
3636

3737
expect(result.success).toBe(true);
38-
expect(result.entries.length).toBe(3);
3938
expect(result.total_count).toBe(3);
4039
expect(result.depth_used).toBe(1);
4140

42-
// Check sorting: directories first
43-
expect(result.entries[0].name).toBe("subdir");
44-
expect(result.entries[0].type).toBe("directory");
45-
expect(result.entries[1].name).toBe("file1.txt");
46-
expect(result.entries[1].type).toBe("file");
47-
expect(result.entries[2].name).toBe("file2.txt");
41+
// Check output contains expected entries
42+
expect(result.output).toContain("subdir/");
43+
expect(result.output).toContain("file1.txt");
44+
expect(result.output).toContain("file2.txt");
45+
46+
// Check sorting: directories first (subdir appears before files in output)
47+
expect(result.output.indexOf("subdir/")).toBeLessThan(result.output.indexOf("file1.txt"));
4848
});
4949

5050
test("lists files recursively (depth 2)", async () => {
@@ -65,11 +65,13 @@ describe("file_list tool", () => {
6565
expect(result.total_count).toBe(3); // dir1, dir1/file1.txt, root.txt
6666
expect(result.depth_used).toBe(2);
6767

68-
const dir1 = result.entries.find((e) => e.name === "dir1");
69-
expect(dir1).toBeDefined();
70-
expect(dir1!.children).toBeDefined();
71-
expect(dir1!.children!.length).toBe(1);
72-
expect(dir1!.children![0].name).toBe("file1.txt");
68+
// Check output shows nested structure
69+
expect(result.output).toContain("dir1/");
70+
expect(result.output).toContain("file1.txt");
71+
expect(result.output).toContain("root.txt");
72+
73+
// Check indentation shows nesting (file1.txt should be indented under dir1)
74+
expect(result.output).toMatch(/dir1\/\s*\n.*file1\.txt/);
7375
});
7476

7577
test("shows file sizes", async () => {
@@ -84,7 +86,8 @@ describe("file_list tool", () => {
8486
)) as Extract<FileListToolResult, { success: true }>;
8587

8688
expect(result.success).toBe(true);
87-
expect(result.entries[0].size).toBe(100);
89+
// Check output includes file size
90+
expect(result.output).toMatch(/file\.txt.*\(100B\)/);
8891
});
8992

9093
test("empty directory", async () => {
@@ -96,8 +99,8 @@ describe("file_list tool", () => {
9699
)) as Extract<FileListToolResult, { success: true }>;
97100

98101
expect(result.success).toBe(true);
99-
expect(result.entries.length).toBe(0);
100102
expect(result.total_count).toBe(0);
103+
expect(result.output).toBe("(empty directory)");
101104
});
102105
});
103106

@@ -116,8 +119,10 @@ describe("file_list tool", () => {
116119
)) as Extract<FileListToolResult, { success: true }>;
117120

118121
expect(result.success).toBe(true);
119-
expect(result.entries.length).toBe(2);
120-
expect(result.entries.every((e) => e.name.endsWith(".ts"))).toBe(true);
122+
expect(result.total_count).toBe(2);
123+
expect(result.output).toContain("file1.ts");
124+
expect(result.output).toContain("file3.ts");
125+
expect(result.output).not.toContain("file2.js");
121126
});
122127

123128
test("prunes empty directories when using pattern", async () => {
@@ -138,8 +143,10 @@ describe("file_list tool", () => {
138143

139144
expect(result.success).toBe(true);
140145
// Should only include hasTs directory (not noTs)
141-
expect(result.entries.length).toBe(1);
142-
expect(result.entries[0].name).toBe("hasTs");
146+
expect(result.total_count).toBe(2); // hasTs dir + file.ts inside
147+
expect(result.output).toContain("hasTs/");
148+
expect(result.output).toContain("file.ts");
149+
expect(result.output).not.toContain("noTs");
143150
});
144151
});
145152

@@ -163,10 +170,10 @@ describe("file_list tool", () => {
163170

164171
expect(result.success).toBe(true);
165172
// Should include .gitignore and included.txt, but not ignored.txt or node_modules
166-
expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true);
167-
expect(result.entries.some((e) => e.name === "included.txt")).toBe(true);
168-
expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(false);
169-
expect(result.entries.some((e) => e.name === "node_modules")).toBe(false);
173+
expect(result.output).toContain(".gitignore");
174+
expect(result.output).toContain("included.txt");
175+
expect(result.output).not.toContain("ignored.txt");
176+
expect(result.output).not.toContain("node_modules");
170177
});
171178

172179
test("shows all files when gitignore=false", async () => {
@@ -183,7 +190,7 @@ describe("file_list tool", () => {
183190
)) as Extract<FileListToolResult, { success: true }>;
184191

185192
expect(result.success).toBe(true);
186-
expect(result.entries.some((e) => e.name === "ignored.txt")).toBe(true);
193+
expect(result.output).toContain("ignored.txt");
187194
});
188195

189196
test("always hides .git directory", async () => {
@@ -200,7 +207,7 @@ describe("file_list tool", () => {
200207
)) as Extract<FileListToolResult, { success: true }>;
201208

202209
expect(result.success).toBe(true);
203-
expect(result.entries.some((e) => e.name === ".git")).toBe(false);
210+
expect(result.output).not.toContain(".git");
204211
});
205212

206213
test("shows hidden files (dotfiles)", async () => {
@@ -216,8 +223,8 @@ describe("file_list tool", () => {
216223
)) as Extract<FileListToolResult, { success: true }>;
217224

218225
expect(result.success).toBe(true);
219-
expect(result.entries.some((e) => e.name === ".env")).toBe(true);
220-
expect(result.entries.some((e) => e.name === ".gitignore")).toBe(true);
226+
expect(result.output).toContain(".env");
227+
expect(result.output).toContain(".gitignore");
221228
});
222229
});
223230

@@ -352,9 +359,10 @@ describe("file_list tool", () => {
352359
)) as Extract<FileListToolResult, { success: true }>;
353360

354361
expect(result.success).toBe(true);
355-
const dir = result.entries.find((e) => e.name === "dir1");
356-
expect(dir).toBeDefined();
357-
expect(dir!.children).toBeUndefined(); // No children at depth 1
362+
expect(result.output).toContain("dir1/");
363+
expect(result.output).toContain("root.txt");
364+
// At depth 1, nested.txt should NOT appear (not traversed)
365+
expect(result.output).not.toContain("nested.txt");
358366
});
359367
});
360368
});

src/services/tools/file_list.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,43 @@ interface TraversalResult {
2424
exceeded: boolean;
2525
}
2626

27+
/**
28+
* Format a file tree as a string with tree characters (├─, └─, │)
29+
* Recursively formats the tree structure for display to LLM
30+
*/
31+
function formatTreeAsString(entries: FileEntry[], indent = "", isLast: boolean[] = []): string {
32+
const lines: string[] = [];
33+
34+
entries.forEach((entry, i) => {
35+
const isLastEntry = i === entries.length - 1;
36+
const prefix = isLast.length > 0 ? indent + (isLastEntry ? "└─ " : "├─ ") : "";
37+
38+
const suffix = entry.type === "directory" ? "/" : "";
39+
const sizeInfo = entry.size !== undefined ? ` (${formatSize(entry.size)})` : "";
40+
41+
lines.push(`${prefix}${entry.name}${suffix}${sizeInfo}`);
42+
43+
// Recursively render children if present
44+
if (entry.children && entry.children.length > 0) {
45+
const newIndent = indent + (isLastEntry ? " " : "│ ");
46+
lines.push(
47+
...formatTreeAsString(entry.children, newIndent, [...isLast, isLastEntry]).split("\n")
48+
);
49+
}
50+
});
51+
52+
return lines.join("\n");
53+
}
54+
55+
/**
56+
* Format a file size in bytes to a human-readable string
57+
*/
58+
function formatSize(bytes: number): string {
59+
if (bytes < 1024) return `${bytes}B`;
60+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
61+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
62+
}
63+
2764
/**
2865
* Load and parse .gitignore file if it exists
2966
*/
@@ -267,10 +304,14 @@ export function createFileListTool(config: { cwd: string }) {
267304
};
268305
}
269306

307+
// Format tree as string for LLM (token efficient)
308+
const output =
309+
result.entries.length === 0 ? "(empty directory)" : formatTreeAsString(result.entries);
310+
270311
return {
271312
success: true,
272313
path: resolvedPath,
273-
entries: result.entries,
314+
output: output,
274315
total_count: result.totalCount,
275316
depth_used: effectiveDepth,
276317
};

src/types/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export type FileListToolResult =
176176
| {
177177
success: true;
178178
path: string; // Resolved absolute path that was listed
179-
entries: FileEntry[]; // Top-level entries (recursive structure)
179+
output: string; // Formatted tree structure as string
180180
total_count: number; // Total entries across all levels
181181
depth_used: number; // Maximum depth traversed
182182
}

0 commit comments

Comments
 (0)