Skip to content

Commit 1733656

Browse files
committed
feat(codebase): add metadata support for file and folder summaries in tree structure
1 parent 0b5ab45 commit 1733656

3 files changed

Lines changed: 140 additions & 43 deletions

File tree

src/client-pipe.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export interface CodebaseTreeNode {
232232
type: 'directory' | 'file';
233233
children?: CodebaseTreeNode[];
234234
symbols?: CodebaseSymbolNode[];
235+
lineCount?: number;
235236
}
236237

237238
export interface CodebaseOverviewResult {
@@ -392,10 +393,11 @@ export async function codebaseGetOverview(
392393
fileTypes: string | string[],
393394
symbols: boolean,
394395
timeout?: number,
396+
metadata?: boolean,
395397
): Promise<CodebaseOverviewResult> {
396398
const result = await sendClientRequest(
397399
'codebase.getOverview',
398-
{rootDir, folderPath, recursive, fileTypes, symbols},
400+
{rootDir, folderPath, recursive, fileTypes, symbols, metadata},
399401
timeout ?? 30_000,
400402
);
401403
assertResult<CodebaseOverviewResult>(result, 'codebase.getOverview');

src/tools/codebase/codebase-map.ts

Lines changed: 118 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,42 @@ function estimateTokens(text: string): number {
2929
return Math.ceil(text.length / CHARS_PER_TOKEN);
3030
}
3131

32-
function normalizePath(p: string): string {
33-
return p.replace(/\\/g, '/');
32+
// ── Formatting ───────────────────────────────────────────
33+
34+
function countSymbolsDeep(symbols: CodebaseSymbolNode[]): number {
35+
let count = symbols.length;
36+
for (const s of symbols) {
37+
if (s.children) count += countSymbolsDeep(s.children);
38+
}
39+
return count;
3440
}
3541

36-
// ── Formatting ───────────────────────────────────────────
42+
function countImmediateFiles(nodes: CodebaseTreeNode[]): number {
43+
let count = 0;
44+
for (const n of nodes) {
45+
if (n.type === 'file') count++;
46+
}
47+
return count;
48+
}
3749

38-
function formatSymbol(symbol: CodebaseSymbolNode, depth: number): string {
50+
function countImmediateSubfolders(nodes: CodebaseTreeNode[]): number {
51+
let count = 0;
52+
for (const n of nodes) {
53+
if (n.type === 'directory') count++;
54+
}
55+
return count;
56+
}
57+
58+
function plural(n: number, singular: string, pluralForm: string): string {
59+
return n === 1 ? `${n} ${singular}` : `${n} ${pluralForm}`;
60+
}
61+
62+
function formatSymbol(symbol: CodebaseSymbolNode, depth: number, maxSymbolDepth?: number, currentSymbolDepth = 0): string {
3963
const indent = INDENT.repeat(depth);
4064
let output = `${indent}${symbol.kind} ${symbol.name}\n`;
41-
if (symbol.children) {
65+
if (symbol.children && (maxSymbolDepth === undefined || currentSymbolDepth < maxSymbolDepth)) {
4266
for (const child of symbol.children) {
43-
output += formatSymbol(child, depth + 1);
67+
output += formatSymbol(child, depth + 1, maxSymbolDepth, currentSymbolDepth + 1);
4468
}
4569
}
4670
return output;
@@ -50,37 +74,57 @@ function formatTree(
5074
nodes: CodebaseTreeNode[],
5175
showSymbols: boolean,
5276
showFiles: boolean,
77+
showMetadata: boolean,
5378
depth: number = 0,
79+
maxSymbolDepth?: number,
5480
): string {
5581
let output = '';
5682
const indent = INDENT.repeat(depth);
5783

5884
for (const node of nodes) {
5985
if (node.type === 'directory') {
60-
output += `${indent}${node.name}/\n`;
86+
if (showMetadata && node.children) {
87+
const files = countImmediateFiles(node.children);
88+
const subs = countImmediateSubfolders(node.children);
89+
output += `${indent}[${plural(files, 'file', 'files')}, ${plural(subs, 'subfolder', 'subfolders')}] ${node.name}/\n`;
90+
} else {
91+
output += `${indent}${node.name}/\n`;
92+
}
6193
if (node.children) {
62-
output += formatTree(node.children, showSymbols, showFiles, depth + 1);
94+
output += formatTree(node.children, showSymbols, showFiles, showMetadata, depth + 1, maxSymbolDepth);
6395
}
6496
} else if (node.type === 'file' && showFiles) {
65-
output += `${indent}${node.name}\n`;
97+
if (showMetadata) {
98+
const linePart = node.lineCount != null ? `[${plural(node.lineCount, 'line', 'lines')}] ` : '';
99+
const symPart = node.symbols ? `[${plural(countSymbolsDeep(node.symbols), 'symbol', 'symbols')}] ` : '';
100+
output += `${indent}${linePart}${symPart}${node.name}\n`;
101+
} else {
102+
output += `${indent}${node.name}\n`;
103+
}
66104
if (showSymbols && node.symbols) {
67105
for (const sym of node.symbols) {
68-
output += formatSymbol(sym, depth + 1);
106+
output += formatSymbol(sym, depth + 1, maxSymbolDepth);
69107
}
70108
}
71109
}
72110
}
73111
return output;
74112
}
75113

76-
function formatFlatPaths(nodes: CodebaseTreeNode[], prefix = ''): string {
114+
function formatFlatPaths(nodes: CodebaseTreeNode[], showMetadata: boolean, prefix = ''): string {
77115
let output = '';
78116
for (const node of nodes) {
79117
const p = prefix ? `${prefix}/${node.name}` : node.name;
80118
if (node.type === 'file') {
81-
output += p + '\n';
119+
if (showMetadata) {
120+
const linePart = node.lineCount != null ? `[${plural(node.lineCount, 'line', 'lines')}] ` : '';
121+
const symPart = node.symbols ? `[${plural(countSymbolsDeep(node.symbols), 'symbol', 'symbols')}] ` : '';
122+
output += `${linePart}${symPart}${p}\n`;
123+
} else {
124+
output += p + '\n';
125+
}
82126
} else if (node.children) {
83-
output += formatFlatPaths(node.children, p);
127+
output += formatFlatPaths(node.children, showMetadata, p);
84128
}
85129
}
86130
return output;
@@ -92,8 +136,9 @@ function formatFolderSummary(nodes: CodebaseTreeNode[], depth = 0): string {
92136

93137
for (const node of nodes) {
94138
if (node.type === 'directory') {
95-
const fileCount = countFiles(node);
96-
output += `${indent}${node.name}/ (${fileCount} files)\n`;
139+
const files = node.children ? countImmediateFiles(node.children) : 0;
140+
const subs = node.children ? countImmediateSubfolders(node.children) : 0;
141+
output += `${indent}[${plural(files, 'file', 'files')}, ${plural(subs, 'subfolder', 'subfolders')}] ${node.name}/\n`;
97142
if (node.children) {
98143
output += formatFolderSummary(node.children, depth + 1);
99144
}
@@ -113,6 +158,28 @@ function countFiles(node: CodebaseTreeNode): number {
113158
return count;
114159
}
115160

161+
function maxSymbolTreeDepth(symbols: CodebaseSymbolNode[], current = 0): number {
162+
let max = current;
163+
for (const s of symbols) {
164+
if (s.children && s.children.length > 0) {
165+
max = Math.max(max, maxSymbolTreeDepth(s.children, current + 1));
166+
}
167+
}
168+
return max;
169+
}
170+
171+
function maxTreeSymbolDepth(nodes: CodebaseTreeNode[]): number {
172+
let max = 0;
173+
for (const node of nodes) {
174+
if (node.type === 'file' && node.symbols) {
175+
max = Math.max(max, maxSymbolTreeDepth(node.symbols));
176+
} else if (node.type === 'directory' && node.children) {
177+
max = Math.max(max, maxTreeSymbolDepth(node.children));
178+
}
179+
}
180+
return max;
181+
}
182+
116183
// ── Tool Definition ──────────────────────────────────────
117184

118185
export const map = defineTool({
@@ -123,13 +190,15 @@ export const map = defineTool({
123190
'- `folderPath` — Folder to map (relative or absolute). Defaults to workspace root.\n' +
124191
'- `recursive` — Include subdirectories recursively. Default: false (immediate children only).\n' +
125192
'- `fileTypes` — Which files to include: `"*"` (all), `"none"` (folders only), or array of extensions.\n' +
126-
'- `symbols` — Include symbol skeleton (name + kind, hierarchically nested). Default: false.\n\n' +
193+
'- `symbols` — Include symbol skeleton (name + kind, hierarchically nested). Default: false.\n' +
194+
'- `metadata` — Show counts: `[N lines] [M symbols]` per file, `[N files, M subfolders]` per folder. Default: false.\n\n' +
127195
'**EXAMPLES:**\n' +
128196
'- Shallow view of root: `{}`\n' +
129197
'- Full project tree: `{ recursive: true }`\n' +
130198
'- Only TypeScript files: `{ fileTypes: [".ts"], recursive: true }`\n' +
131199
'- Folder structure only: `{ fileTypes: "none", recursive: true }`\n' +
132200
'- Specific folder with symbols: `{ folderPath: "src", recursive: true, symbols: true }`\n' +
201+
'- Tree with metadata: `{ recursive: true, metadata: true }`\n' +
133202
'- Only CSS files in a subfolder: `{ folderPath: "src/styles", fileTypes: [".css", ".scss"] }`',
134203
annotations: {
135204
title: 'Codebase Map',
@@ -156,6 +225,9 @@ export const map = defineTool({
156225

157226
symbols: zod.boolean().optional()
158227
.describe('Include symbol skeleton (name + kind, hierarchically nested). Default: false.'),
228+
229+
metadata: zod.boolean().optional()
230+
.describe('Show counts per file ([N lines] [M symbols]) and per folder ([N files, M subfolders]). Default: false.'),
159231
},
160232
handler: async (request, response) => {
161233
const {params} = request;
@@ -166,6 +238,7 @@ export const map = defineTool({
166238
const recursive = params.recursive ?? false;
167239
const fileTypes = params.fileTypes ?? '*';
168240
const symbols = params.symbols ?? false;
241+
const metadata = params.metadata ?? false;
169242
const isNone = fileTypes === 'none';
170243

171244
// Dynamic timeout based on request scope
@@ -181,11 +254,11 @@ export const map = defineTool({
181254
fileTypes,
182255
symbols,
183256
dynamicTimeout,
257+
metadata,
184258
);
185259

186260
if (overviewResult.summary.totalFiles === 0 && !isNone) {
187261
const ignoreContext = readIgnoreContext(overviewResult.projectRoot);
188-
response.appendResponseLine(`Root: ${normalizePath(overviewResult.projectRoot)}\n`);
189262
response.appendResponseLine('No files found. Check scope patterns or .devtoolsignore.\n');
190263
if (ignoreContext.activePatterns.length > 0) {
191264
response.appendResponseLine('Current .devtoolsignore patterns:\n');
@@ -199,36 +272,54 @@ export const map = defineTool({
199272
// Build output
200273
let showFiles = !isNone;
201274
let showSymbols = symbols;
202-
let output = formatTree(overviewResult.tree, showSymbols, showFiles);
275+
let showMetadata = metadata;
276+
let output = formatTree(overviewResult.tree, showSymbols, showFiles, showMetadata);
203277
const reductionsApplied: string[] = [];
204278

205-
// Adaptive compression — progressively reduce detail if output exceeds token limit
279+
// Adaptive compression — progressively reduce symbol depth before removing symbols entirely
206280
if (estimateTokens(output) > OUTPUT_TOKEN_LIMIT && showSymbols) {
207-
showSymbols = false;
208-
reductionsApplied.push('remove-symbols');
209-
output = formatTree(overviewResult.tree, showSymbols, showFiles);
281+
showMetadata = true;
282+
const deepest = maxTreeSymbolDepth(overviewResult.tree);
283+
284+
// Try each depth level from deepest-1 down to 0 (top-level symbols only)
285+
for (let d = deepest - 1; d >= 0; d--) {
286+
output = formatTree(overviewResult.tree, showSymbols, showFiles, showMetadata, 0, d);
287+
if (estimateTokens(output) <= OUTPUT_TOKEN_LIMIT) {
288+
reductionsApplied.push(`symbol-depth-${d}`);
289+
break;
290+
}
291+
}
292+
293+
// If even top-level symbols (depth 0) is too large, remove symbols entirely
294+
if (estimateTokens(output) > OUTPUT_TOKEN_LIMIT) {
295+
showSymbols = false;
296+
reductionsApplied.push('remove-symbols');
297+
output = formatTree(overviewResult.tree, showSymbols, showFiles, showMetadata);
298+
}
210299
}
211300

212301
if (estimateTokens(output) > OUTPUT_TOKEN_LIMIT && showFiles) {
213302
showFiles = false;
214303
reductionsApplied.push('folders-only');
215-
output = formatTree(overviewResult.tree, showSymbols, showFiles);
304+
output = formatTree(overviewResult.tree, showSymbols, showFiles, showMetadata);
216305
}
217306

218307
if (estimateTokens(output) > OUTPUT_TOKEN_LIMIT) {
219308
reductionsApplied.push('flat-paths');
220-
output = formatFlatPaths(overviewResult.tree);
309+
output = formatFlatPaths(overviewResult.tree, showMetadata);
221310
}
222311

223312
if (estimateTokens(output) > OUTPUT_TOKEN_LIMIT) {
224313
reductionsApplied.push('folder-summary');
225314
output = formatFolderSummary(overviewResult.tree);
226315
}
227316

228-
response.appendResponseLine(`Root: ${normalizePath(overviewResult.projectRoot)}\n`);
229-
230317
if (reductionsApplied.length > 0) {
231-
response.appendResponseLine(`Compression: ${reductionsApplied.join(' → ')}\n`);
318+
const steps = reductionsApplied.join(' → ');
319+
response.appendResponseLine(
320+
`Output exceeded token limit. Compression applied: ${steps}. ` +
321+
'Use the returned map to navigate from here, or use file_read with a specific folder/file for full detail.\n',
322+
);
232323
}
233324

234325
response.appendResponseLine(output.trimEnd());

src/tools/file/file-read.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function formatSkeletonEntry(
6565
const endLine = 'startLine' in symbol.range ? symbol.range.endLine : symbol.range.end;
6666
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
6767

68-
lines.push(`${indent}[${range}]: ${symbol.kind} ${symbol.name}`);
68+
lines.push(`${indent}[${range}] ${symbol.kind} ${symbol.name}`);
6969

7070
if (recursive && symbol.children && symbol.children.length > 0) {
7171
for (const child of symbol.children) {
@@ -108,7 +108,7 @@ function formatContentWithPlaceholders(
108108
const childRange = child.range.startLine === child.range.endLine
109109
? `${child.range.startLine}`
110110
: `${child.range.startLine}-${child.range.endLine}`;
111-
result.push(`[${childRange}]: ${child.kind} ${child.name}`);
111+
result.push(`[${childRange}] ${child.kind} ${child.name}`);
112112
}
113113
} else {
114114
result.push(`[${lineNum}] ${allLines[lineNum - 1] ?? ''}`);
@@ -330,7 +330,7 @@ function renderStructuredRange(
330330
const symRange = sym.range.startLine === sym.range.endLine
331331
? `${sym.range.startLine}`
332332
: `${sym.range.startLine}-${sym.range.endLine}`;
333-
result.push(`[${symRange}]: ${sym.kind} ${sym.name}`);
333+
result.push(`[${symRange}] ${sym.kind} ${sym.name}`);
334334
}
335335
// Track all lines of this symbol within the range
336336
const symEndInRange = Math.min(sym.range.endLine, expandedEnd);
@@ -354,13 +354,16 @@ function renderStructuredRange(
354354
actualEnd = Math.max(actualEnd, blockEnd);
355355

356356
if (collapseSkeleton && block.type !== 'gap') {
357-
// Collapse imports/exports/comments/directives to stubs
358-
flushSourceRange();
359-
collapsedRanges.push({startLine: block.startLine, endLine: block.endLine});
360-
const blockRange = block.startLine === block.endLine
361-
? `${block.startLine}`
362-
: `${block.startLine}-${block.endLine}`;
363-
result.push(`[${blockRange}]: ${block.type}s`);
357+
// Collapse multi-line imports/exports/comments/directives to stubs
358+
// Single-line blocks show actual content
359+
if (block.startLine === block.endLine) {
360+
trackSourceLine(block.startLine);
361+
result.push(`[${block.startLine}] ${allLines[block.startLine - 1] ?? ''}`);
362+
} else {
363+
flushSourceRange();
364+
collapsedRanges.push({startLine: block.startLine, endLine: block.endLine});
365+
result.push(`[${block.startLine}-${block.endLine}] ${block.type}s`);
366+
}
364367
} else {
365368
// Emit raw source for the block
366369
for (let l = blockStart; l <= blockEnd; l++) {
@@ -631,11 +634,12 @@ export const read = defineTool({
631634
} else if (piece.symbol) {
632635
const entries = formatSkeletonEntry(piece.symbol, '', recursive);
633636
for (const entry of entries) response.appendResponseLine(entry);
637+
} else if (piece.startLine === piece.endLine) {
638+
// Single-line block: show actual content
639+
response.appendResponseLine(`[${piece.startLine}] ${allLines[piece.startLine - 1] ?? ''}`);
634640
} else {
635-
const range = piece.startLine === piece.endLine
636-
? `${piece.startLine}`
637-
: `${piece.startLine}-${piece.endLine}`;
638-
response.appendResponseLine(`[${range}]: ${piece.category}`);
641+
// Multi-line block: show collapsed stub
642+
response.appendResponseLine(`[${piece.startLine}-${piece.endLine}] ${piece.category}`);
639643
}
640644
}
641645

@@ -713,7 +717,7 @@ export const read = defineTool({
713717
fileHighlightReadRange(filePath, startLine - 1, endLine - 1);
714718

715719
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
716-
response.appendResponseLine(`[${range}]: ${symbol.kind} ${symbol.name}`);
720+
response.appendResponseLine(`[${range}] ${symbol.kind} ${symbol.name}`);
717721

718722
if (recursive || !symbol.children || symbol.children.length === 0) {
719723
const numbered = addLineNumbers(

0 commit comments

Comments
 (0)