@hasna/terminal 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp/server.js +16 -1
- package/dist/search/index.js +1 -0
- package/dist/search/semantic.js +224 -0
- package/dist/smart-display.js +97 -1
- package/package.json +3 -2
- package/src/mcp/server.ts +23 -1
- package/src/search/index.ts +2 -0
- package/src/search/semantic.ts +267 -0
- package/src/smart-display.ts +97 -1
package/dist/mcp/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { spawn } from "child_process";
|
|
|
6
6
|
import { compress, stripAnsi } from "../compression.js";
|
|
7
7
|
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
8
8
|
import { summarizeOutput } from "../ai.js";
|
|
9
|
-
import { searchFiles, searchContent } from "../search/index.js";
|
|
9
|
+
import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
|
|
10
10
|
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
11
11
|
import { substituteVariables } from "../recipes/model.js";
|
|
12
12
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
@@ -185,6 +185,21 @@ export function createServer() {
|
|
|
185
185
|
const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
|
|
186
186
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
187
187
|
});
|
|
188
|
+
// ── search_semantic: AST-powered code search ───────────────────────────────
|
|
189
|
+
server.tool("search_semantic", "Semantic code search — find functions, classes, components, hooks, types by meaning. Uses AST parsing, not string matching. Much more precise than grep for code navigation.", {
|
|
190
|
+
query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
|
|
191
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
192
|
+
kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
|
|
193
|
+
exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
|
|
194
|
+
maxResults: z.number().optional().describe("Max results (default: 30)"),
|
|
195
|
+
}, async ({ query, path, kinds, exportedOnly, maxResults }) => {
|
|
196
|
+
const result = await semanticSearch(query, path ?? process.cwd(), {
|
|
197
|
+
kinds: kinds,
|
|
198
|
+
exportedOnly,
|
|
199
|
+
maxResults,
|
|
200
|
+
});
|
|
201
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
202
|
+
});
|
|
188
203
|
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
189
204
|
server.tool("list_recipes", "List saved command recipes. Optionally filter by collection or project.", {
|
|
190
205
|
collection: z.string().optional().describe("Filter by collection name"),
|
package/dist/search/index.js
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
export { searchFiles } from "./file-search.js";
|
|
3
3
|
export { searchContent } from "./content-search.js";
|
|
4
4
|
export { DEFAULT_EXCLUDE_DIRS, SOURCE_EXTENSIONS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
|
|
5
|
+
export { semanticSearch, findExports, findComponents, findHooks } from "./semantic.js";
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Semantic code search — AST-powered search that understands code structure
|
|
2
|
+
// Instead of raw grep, searches by meaning: "find auth functions" → login(), verifyToken()
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
function exec(command, cwd) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
9
|
+
let out = "";
|
|
10
|
+
proc.stdout?.on("data", (d) => { out += d.toString(); });
|
|
11
|
+
proc.stderr?.on("data", (d) => { });
|
|
12
|
+
proc.on("close", () => resolve(out));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Extract code symbols from a TypeScript/JavaScript file using regex-based parsing */
|
|
16
|
+
function extractSymbols(filePath) {
|
|
17
|
+
if (!existsSync(filePath))
|
|
18
|
+
return [];
|
|
19
|
+
const content = readFileSync(filePath, "utf8");
|
|
20
|
+
const lines = content.split("\n");
|
|
21
|
+
const symbols = [];
|
|
22
|
+
const file = filePath;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i];
|
|
25
|
+
const lineNum = i + 1;
|
|
26
|
+
const isExported = line.trimStart().startsWith("export");
|
|
27
|
+
// Functions: export function X(...) or export const X = (...) =>
|
|
28
|
+
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
|
|
29
|
+
if (funcMatch) {
|
|
30
|
+
const prevLine = i > 0 ? lines[i - 1] : "";
|
|
31
|
+
const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
|
|
32
|
+
? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
|
|
33
|
+
: undefined;
|
|
34
|
+
symbols.push({
|
|
35
|
+
name: funcMatch[1], kind: "function", file, line: lineNum,
|
|
36
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
37
|
+
exported: isExported, doc,
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// Arrow functions: export const X = (...) =>
|
|
42
|
+
const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
|
|
43
|
+
if (arrowMatch) {
|
|
44
|
+
// Detect React hooks
|
|
45
|
+
const isHook = arrowMatch[1].startsWith("use");
|
|
46
|
+
const isComponent = /^[A-Z]/.test(arrowMatch[1]);
|
|
47
|
+
symbols.push({
|
|
48
|
+
name: arrowMatch[1],
|
|
49
|
+
kind: isHook ? "hook" : isComponent ? "component" : "function",
|
|
50
|
+
file, line: lineNum,
|
|
51
|
+
signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
|
|
52
|
+
exported: isExported,
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Classes
|
|
57
|
+
const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
|
|
58
|
+
if (classMatch) {
|
|
59
|
+
symbols.push({
|
|
60
|
+
name: classMatch[1], kind: "class", file, line: lineNum,
|
|
61
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
62
|
+
exported: isExported,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Interfaces
|
|
67
|
+
const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
|
|
68
|
+
if (ifaceMatch) {
|
|
69
|
+
symbols.push({
|
|
70
|
+
name: ifaceMatch[1], kind: "interface", file, line: lineNum,
|
|
71
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
72
|
+
exported: isExported,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Type aliases
|
|
77
|
+
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
|
|
78
|
+
if (typeMatch) {
|
|
79
|
+
symbols.push({
|
|
80
|
+
name: typeMatch[1], kind: "type", file, line: lineNum,
|
|
81
|
+
signature: line.trim(),
|
|
82
|
+
exported: isExported,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Imports (for dependency tracking)
|
|
87
|
+
const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
|
|
88
|
+
if (importMatch) {
|
|
89
|
+
const names = importMatch[1]
|
|
90
|
+
? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
|
|
91
|
+
: [importMatch[2]];
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
if (name) {
|
|
94
|
+
symbols.push({
|
|
95
|
+
name, kind: "import", file, line: lineNum,
|
|
96
|
+
signature: `from '${importMatch[3]}'`,
|
|
97
|
+
exported: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Exported constants/variables
|
|
104
|
+
const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
|
|
105
|
+
if (constMatch && !arrowMatch) {
|
|
106
|
+
symbols.push({
|
|
107
|
+
name: constMatch[1], kind: "variable", file, line: lineNum,
|
|
108
|
+
signature: line.trim().slice(0, 80),
|
|
109
|
+
exported: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return symbols;
|
|
114
|
+
}
|
|
115
|
+
/** Find all source files in a directory */
|
|
116
|
+
async function findSourceFiles(cwd, maxFiles = 200) {
|
|
117
|
+
const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
|
|
118
|
+
const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
|
|
119
|
+
const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
|
|
120
|
+
const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
|
|
121
|
+
const output = await exec(cmd, cwd);
|
|
122
|
+
return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
|
|
123
|
+
}
|
|
124
|
+
/** Semantic search: find symbols matching a natural language query */
|
|
125
|
+
export async function semanticSearch(query, cwd, options = {}) {
|
|
126
|
+
const { kinds, exportedOnly = false, maxResults = 30 } = options;
|
|
127
|
+
// Find all source files
|
|
128
|
+
const files = await findSourceFiles(cwd);
|
|
129
|
+
// Extract symbols from all files
|
|
130
|
+
let allSymbols = [];
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
try {
|
|
133
|
+
allSymbols.push(...extractSymbols(file));
|
|
134
|
+
}
|
|
135
|
+
catch { /* skip unreadable files */ }
|
|
136
|
+
}
|
|
137
|
+
// Filter by kind
|
|
138
|
+
if (kinds) {
|
|
139
|
+
allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
|
|
140
|
+
}
|
|
141
|
+
// Filter by exported
|
|
142
|
+
if (exportedOnly) {
|
|
143
|
+
allSymbols = allSymbols.filter(s => s.exported);
|
|
144
|
+
}
|
|
145
|
+
// Score each symbol against the query
|
|
146
|
+
const queryLower = query.toLowerCase();
|
|
147
|
+
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
148
|
+
const scored = allSymbols.map(symbol => {
|
|
149
|
+
let score = 0;
|
|
150
|
+
const nameLower = symbol.name.toLowerCase();
|
|
151
|
+
const sigLower = (symbol.signature ?? "").toLowerCase();
|
|
152
|
+
const fileLower = symbol.file.toLowerCase();
|
|
153
|
+
// Exact name match
|
|
154
|
+
if (queryWords.some(w => nameLower === w))
|
|
155
|
+
score += 10;
|
|
156
|
+
// Name contains query word
|
|
157
|
+
if (queryWords.some(w => nameLower.includes(w)))
|
|
158
|
+
score += 5;
|
|
159
|
+
// Signature contains query word
|
|
160
|
+
if (queryWords.some(w => sigLower.includes(w)))
|
|
161
|
+
score += 3;
|
|
162
|
+
// File path contains query word
|
|
163
|
+
if (queryWords.some(w => fileLower.includes(w)))
|
|
164
|
+
score += 2;
|
|
165
|
+
// Doc contains query word
|
|
166
|
+
if (symbol.doc && queryWords.some(w => symbol.doc.toLowerCase().includes(w)))
|
|
167
|
+
score += 4;
|
|
168
|
+
// Boost exported symbols
|
|
169
|
+
if (symbol.exported)
|
|
170
|
+
score += 1;
|
|
171
|
+
// Boost functions/classes over imports
|
|
172
|
+
if (symbol.kind === "function" || symbol.kind === "class")
|
|
173
|
+
score += 1;
|
|
174
|
+
// Semantic matching for common patterns
|
|
175
|
+
if (queryLower.includes("component") && symbol.kind === "component")
|
|
176
|
+
score += 5;
|
|
177
|
+
if (queryLower.includes("hook") && symbol.kind === "hook")
|
|
178
|
+
score += 5;
|
|
179
|
+
if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface"))
|
|
180
|
+
score += 5;
|
|
181
|
+
if (queryLower.includes("import") && symbol.kind === "import")
|
|
182
|
+
score += 5;
|
|
183
|
+
if (queryLower.includes("class") && symbol.kind === "class")
|
|
184
|
+
score += 5;
|
|
185
|
+
return { symbol, score };
|
|
186
|
+
});
|
|
187
|
+
// Sort by score, filter zero scores
|
|
188
|
+
const results = scored
|
|
189
|
+
.filter(s => s.score > 0)
|
|
190
|
+
.sort((a, b) => b.score - a.score)
|
|
191
|
+
.slice(0, maxResults)
|
|
192
|
+
.map(s => s.symbol);
|
|
193
|
+
// Make file paths relative
|
|
194
|
+
for (const r of results) {
|
|
195
|
+
if (r.file.startsWith(cwd)) {
|
|
196
|
+
r.file = "." + r.file.slice(cwd.length);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Estimate token savings
|
|
200
|
+
const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
|
|
201
|
+
const rawTokens = Math.ceil(rawGrep.length / 4);
|
|
202
|
+
const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
|
|
203
|
+
return {
|
|
204
|
+
query,
|
|
205
|
+
symbols: results,
|
|
206
|
+
totalFiles: files.length,
|
|
207
|
+
tokensSaved: Math.max(0, rawTokens - resultTokens),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** Quick helper: find all exported functions */
|
|
211
|
+
export async function findExports(cwd) {
|
|
212
|
+
const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
|
|
213
|
+
return result.symbols;
|
|
214
|
+
}
|
|
215
|
+
/** Quick helper: find all React components */
|
|
216
|
+
export async function findComponents(cwd) {
|
|
217
|
+
const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
|
|
218
|
+
return result.symbols;
|
|
219
|
+
}
|
|
220
|
+
/** Quick helper: find all hooks */
|
|
221
|
+
export async function findHooks(cwd) {
|
|
222
|
+
const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
|
|
223
|
+
return result.symbols;
|
|
224
|
+
}
|
package/dist/smart-display.js
CHANGED
|
@@ -78,6 +78,10 @@ function collapseNodeModules(paths) {
|
|
|
78
78
|
export function smartDisplay(lines) {
|
|
79
79
|
if (lines.length <= 5)
|
|
80
80
|
return lines;
|
|
81
|
+
// Try ls -la table compression first
|
|
82
|
+
const lsCompressed = compressLsTable(lines);
|
|
83
|
+
if (lsCompressed)
|
|
84
|
+
return lsCompressed;
|
|
81
85
|
if (!looksLikePaths(lines))
|
|
82
86
|
return compressGeneric(lines);
|
|
83
87
|
const paths = lines.map(l => l.trim()).filter(l => l);
|
|
@@ -114,7 +118,9 @@ export function smartDisplay(lines) {
|
|
|
114
118
|
const sorted = files.sort();
|
|
115
119
|
const pattern = findPattern(sorted);
|
|
116
120
|
if (pattern) {
|
|
117
|
-
|
|
121
|
+
const dateRange = collapseDateRange(sorted);
|
|
122
|
+
const rangeStr = dateRange ? ` (${dateRange})` : "";
|
|
123
|
+
result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
|
|
118
124
|
}
|
|
119
125
|
else {
|
|
120
126
|
result.push(` ${dir}/ (${files.length} files)`);
|
|
@@ -150,6 +156,96 @@ export function smartDisplay(lines) {
|
|
|
150
156
|
}
|
|
151
157
|
return result;
|
|
152
158
|
}
|
|
159
|
+
/** Detect date range in timestamps and collapse */
|
|
160
|
+
function collapseDateRange(files) {
|
|
161
|
+
const timestamps = [];
|
|
162
|
+
for (const f of files) {
|
|
163
|
+
const match = f.match(/(\d{4})-(\d{2})-(\d{2})T?(\d{2})?/);
|
|
164
|
+
if (match) {
|
|
165
|
+
const [, y, m, d, h] = match;
|
|
166
|
+
timestamps.push(new Date(`${y}-${m}-${d}T${h ?? "00"}:00:00`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (timestamps.length < 2)
|
|
170
|
+
return null;
|
|
171
|
+
timestamps.sort((a, b) => a.getTime() - b.getTime());
|
|
172
|
+
const first = timestamps[0];
|
|
173
|
+
const last = timestamps[timestamps.length - 1];
|
|
174
|
+
const fmt = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
|
|
175
|
+
if (first.toDateString() === last.toDateString()) {
|
|
176
|
+
return `${fmt(first)}`;
|
|
177
|
+
}
|
|
178
|
+
return `${fmt(first)}–${fmt(last)}`;
|
|
179
|
+
}
|
|
180
|
+
/** Detect and compress ls -la style table output */
|
|
181
|
+
function compressLsTable(lines) {
|
|
182
|
+
// Detect ls -la format: permissions size date name
|
|
183
|
+
const lsPattern = /^[dlcbps-][rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+\S+\s+\w+\s+\d+\s+[\d:]+\s+.+$/;
|
|
184
|
+
const isLsOutput = lines.filter(l => lsPattern.test(l.trim())).length > lines.length * 0.5;
|
|
185
|
+
if (!isLsOutput)
|
|
186
|
+
return null;
|
|
187
|
+
const result = [];
|
|
188
|
+
const dirs = [];
|
|
189
|
+
const files = [];
|
|
190
|
+
let totalSize = 0;
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
const match = line.trim().match(/^([dlcbps-])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\S+)\s+\w+\s+\d+\s+[\d:]+\s+(.+)$/);
|
|
193
|
+
if (!match) {
|
|
194
|
+
if (line.trim().startsWith("total "))
|
|
195
|
+
continue;
|
|
196
|
+
result.push(line);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const [, type, sizeStr, name] = match;
|
|
200
|
+
const size = parseInt(sizeStr) || 0;
|
|
201
|
+
totalSize += size;
|
|
202
|
+
if (type === "d") {
|
|
203
|
+
dirs.push(name);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
files.push({ name, size: formatSize(size) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Compact display
|
|
210
|
+
if (dirs.length > 0) {
|
|
211
|
+
result.push(` 📁 ${dirs.join(" ")}${dirs.length > 5 ? ` (+${dirs.length - 5} more)` : ""}`);
|
|
212
|
+
}
|
|
213
|
+
if (files.length <= 8) {
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Show top 5 by size + count
|
|
220
|
+
const sorted = files.sort((a, b) => parseSize(b.size) - parseSize(a.size));
|
|
221
|
+
for (const f of sorted.slice(0, 5)) {
|
|
222
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
223
|
+
}
|
|
224
|
+
result.push(` ... +${files.length - 5} more files (${formatSize(totalSize)} total)`);
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
function formatSize(bytes) {
|
|
229
|
+
if (bytes >= 1_000_000)
|
|
230
|
+
return `${(bytes / 1_000_000).toFixed(1)}M`;
|
|
231
|
+
if (bytes >= 1_000)
|
|
232
|
+
return `${(bytes / 1_000).toFixed(1)}K`;
|
|
233
|
+
return `${bytes}B`;
|
|
234
|
+
}
|
|
235
|
+
function parseSize(s) {
|
|
236
|
+
const match = s.match(/([\d.]+)([BKMG])?/);
|
|
237
|
+
if (!match)
|
|
238
|
+
return 0;
|
|
239
|
+
const n = parseFloat(match[1]);
|
|
240
|
+
const unit = match[2];
|
|
241
|
+
if (unit === "K")
|
|
242
|
+
return n * 1000;
|
|
243
|
+
if (unit === "M")
|
|
244
|
+
return n * 1000000;
|
|
245
|
+
if (unit === "G")
|
|
246
|
+
return n * 1000000000;
|
|
247
|
+
return n;
|
|
248
|
+
}
|
|
153
249
|
/** Compress non-path generic output by deduplicating similar lines */
|
|
154
250
|
function compressGeneric(lines) {
|
|
155
251
|
if (lines.length <= 10)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
18
18
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
|
+
"@typescript/vfs": "^1.6.4",
|
|
19
20
|
"better-sqlite3": "^12.8.0",
|
|
20
21
|
"ink": "^5.0.1",
|
|
21
22
|
"react": "^18.2.0",
|
|
@@ -34,6 +35,6 @@
|
|
|
34
35
|
"@types/node": "^20.0.0",
|
|
35
36
|
"@types/react": "^18.2.0",
|
|
36
37
|
"tsx": "^4.0.0",
|
|
37
|
-
"typescript": "^5.
|
|
38
|
+
"typescript": "^5.9.3"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { spawn } from "child_process";
|
|
|
7
7
|
import { compress, stripAnsi } from "../compression.js";
|
|
8
8
|
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
9
9
|
import { summarizeOutput } from "../ai.js";
|
|
10
|
-
import { searchFiles, searchContent } from "../search/index.js";
|
|
10
|
+
import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
|
|
11
11
|
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
12
12
|
import { substituteVariables } from "../recipes/model.js";
|
|
13
13
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
@@ -238,6 +238,28 @@ export function createServer(): McpServer {
|
|
|
238
238
|
}
|
|
239
239
|
);
|
|
240
240
|
|
|
241
|
+
// ── search_semantic: AST-powered code search ───────────────────────────────
|
|
242
|
+
|
|
243
|
+
server.tool(
|
|
244
|
+
"search_semantic",
|
|
245
|
+
"Semantic code search — find functions, classes, components, hooks, types by meaning. Uses AST parsing, not string matching. Much more precise than grep for code navigation.",
|
|
246
|
+
{
|
|
247
|
+
query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
|
|
248
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
249
|
+
kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
|
|
250
|
+
exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
|
|
251
|
+
maxResults: z.number().optional().describe("Max results (default: 30)"),
|
|
252
|
+
},
|
|
253
|
+
async ({ query, path, kinds, exportedOnly, maxResults }) => {
|
|
254
|
+
const result = await semanticSearch(query, path ?? process.cwd(), {
|
|
255
|
+
kinds: kinds as any,
|
|
256
|
+
exportedOnly,
|
|
257
|
+
maxResults,
|
|
258
|
+
});
|
|
259
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
|
|
241
263
|
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
242
264
|
|
|
243
265
|
server.tool(
|
package/src/search/index.ts
CHANGED
|
@@ -5,3 +5,5 @@ export type { FileSearchResult } from "./file-search.js";
|
|
|
5
5
|
export { searchContent } from "./content-search.js";
|
|
6
6
|
export type { ContentSearchResult, ContentFileMatch, ContentMatch } from "./content-search.js";
|
|
7
7
|
export { DEFAULT_EXCLUDE_DIRS, SOURCE_EXTENSIONS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
|
|
8
|
+
export { semanticSearch, findExports, findComponents, findHooks } from "./semantic.js";
|
|
9
|
+
export type { CodeSymbol, SemanticSearchResult } from "./semantic.js";
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Semantic code search — AST-powered search that understands code structure
|
|
2
|
+
// Instead of raw grep, searches by meaning: "find auth functions" → login(), verifyToken()
|
|
3
|
+
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join, extname } from "path";
|
|
7
|
+
|
|
8
|
+
export interface CodeSymbol {
|
|
9
|
+
name: string;
|
|
10
|
+
kind: "function" | "class" | "interface" | "type" | "variable" | "export" | "import" | "component" | "hook";
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
signature?: string; // e.g., "function login(email: string, password: string): Promise<User>"
|
|
14
|
+
exported: boolean;
|
|
15
|
+
doc?: string; // JSDoc comment if present
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SemanticSearchResult {
|
|
19
|
+
query: string;
|
|
20
|
+
symbols: CodeSymbol[];
|
|
21
|
+
totalFiles: number;
|
|
22
|
+
tokensSaved?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exec(command: string, cwd: string): Promise<string> {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
28
|
+
let out = "";
|
|
29
|
+
proc.stdout?.on("data", (d: Buffer) => { out += d.toString(); });
|
|
30
|
+
proc.stderr?.on("data", (d: Buffer) => { /* ignore */ });
|
|
31
|
+
proc.on("close", () => resolve(out));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extract code symbols from a TypeScript/JavaScript file using regex-based parsing */
|
|
36
|
+
function extractSymbols(filePath: string): CodeSymbol[] {
|
|
37
|
+
if (!existsSync(filePath)) return [];
|
|
38
|
+
const content = readFileSync(filePath, "utf8");
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const symbols: CodeSymbol[] = [];
|
|
41
|
+
const file = filePath;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
const lineNum = i + 1;
|
|
46
|
+
const isExported = line.trimStart().startsWith("export");
|
|
47
|
+
|
|
48
|
+
// Functions: export function X(...) or export const X = (...) =>
|
|
49
|
+
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
|
|
50
|
+
if (funcMatch) {
|
|
51
|
+
const prevLine = i > 0 ? lines[i - 1] : "";
|
|
52
|
+
const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
|
|
53
|
+
? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
|
|
54
|
+
: undefined;
|
|
55
|
+
symbols.push({
|
|
56
|
+
name: funcMatch[1], kind: "function", file, line: lineNum,
|
|
57
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
58
|
+
exported: isExported, doc,
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Arrow functions: export const X = (...) =>
|
|
64
|
+
const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
|
|
65
|
+
if (arrowMatch) {
|
|
66
|
+
// Detect React hooks
|
|
67
|
+
const isHook = arrowMatch[1].startsWith("use");
|
|
68
|
+
const isComponent = /^[A-Z]/.test(arrowMatch[1]);
|
|
69
|
+
symbols.push({
|
|
70
|
+
name: arrowMatch[1],
|
|
71
|
+
kind: isHook ? "hook" : isComponent ? "component" : "function",
|
|
72
|
+
file, line: lineNum,
|
|
73
|
+
signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
|
|
74
|
+
exported: isExported,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Classes
|
|
80
|
+
const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
|
|
81
|
+
if (classMatch) {
|
|
82
|
+
symbols.push({
|
|
83
|
+
name: classMatch[1], kind: "class", file, line: lineNum,
|
|
84
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
85
|
+
exported: isExported,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Interfaces
|
|
91
|
+
const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
|
|
92
|
+
if (ifaceMatch) {
|
|
93
|
+
symbols.push({
|
|
94
|
+
name: ifaceMatch[1], kind: "interface", file, line: lineNum,
|
|
95
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
96
|
+
exported: isExported,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Type aliases
|
|
102
|
+
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
|
|
103
|
+
if (typeMatch) {
|
|
104
|
+
symbols.push({
|
|
105
|
+
name: typeMatch[1], kind: "type", file, line: lineNum,
|
|
106
|
+
signature: line.trim(),
|
|
107
|
+
exported: isExported,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Imports (for dependency tracking)
|
|
113
|
+
const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
|
|
114
|
+
if (importMatch) {
|
|
115
|
+
const names = importMatch[1]
|
|
116
|
+
? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
|
|
117
|
+
: [importMatch[2]];
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
if (name) {
|
|
120
|
+
symbols.push({
|
|
121
|
+
name, kind: "import", file, line: lineNum,
|
|
122
|
+
signature: `from '${importMatch[3]}'`,
|
|
123
|
+
exported: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Exported constants/variables
|
|
131
|
+
const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
|
|
132
|
+
if (constMatch && !arrowMatch) {
|
|
133
|
+
symbols.push({
|
|
134
|
+
name: constMatch[1], kind: "variable", file, line: lineNum,
|
|
135
|
+
signature: line.trim().slice(0, 80),
|
|
136
|
+
exported: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return symbols;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Find all source files in a directory */
|
|
145
|
+
async function findSourceFiles(cwd: string, maxFiles: number = 200): Promise<string[]> {
|
|
146
|
+
const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
|
|
147
|
+
const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
|
|
148
|
+
const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
|
|
149
|
+
const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
|
|
150
|
+
const output = await exec(cmd, cwd);
|
|
151
|
+
return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Semantic search: find symbols matching a natural language query */
|
|
155
|
+
export async function semanticSearch(
|
|
156
|
+
query: string,
|
|
157
|
+
cwd: string,
|
|
158
|
+
options: { kinds?: CodeSymbol["kind"][]; exportedOnly?: boolean; maxResults?: number } = {}
|
|
159
|
+
): Promise<SemanticSearchResult> {
|
|
160
|
+
const { kinds, exportedOnly = false, maxResults = 30 } = options;
|
|
161
|
+
|
|
162
|
+
// Find all source files
|
|
163
|
+
const files = await findSourceFiles(cwd);
|
|
164
|
+
|
|
165
|
+
// Extract symbols from all files
|
|
166
|
+
let allSymbols: CodeSymbol[] = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
try {
|
|
169
|
+
allSymbols.push(...extractSymbols(file));
|
|
170
|
+
} catch { /* skip unreadable files */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Filter by kind
|
|
174
|
+
if (kinds) {
|
|
175
|
+
allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Filter by exported
|
|
179
|
+
if (exportedOnly) {
|
|
180
|
+
allSymbols = allSymbols.filter(s => s.exported);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Score each symbol against the query
|
|
184
|
+
const queryLower = query.toLowerCase();
|
|
185
|
+
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
186
|
+
|
|
187
|
+
const scored = allSymbols.map(symbol => {
|
|
188
|
+
let score = 0;
|
|
189
|
+
const nameLower = symbol.name.toLowerCase();
|
|
190
|
+
const sigLower = (symbol.signature ?? "").toLowerCase();
|
|
191
|
+
const fileLower = symbol.file.toLowerCase();
|
|
192
|
+
|
|
193
|
+
// Exact name match
|
|
194
|
+
if (queryWords.some(w => nameLower === w)) score += 10;
|
|
195
|
+
|
|
196
|
+
// Name contains query word
|
|
197
|
+
if (queryWords.some(w => nameLower.includes(w))) score += 5;
|
|
198
|
+
|
|
199
|
+
// Signature contains query word
|
|
200
|
+
if (queryWords.some(w => sigLower.includes(w))) score += 3;
|
|
201
|
+
|
|
202
|
+
// File path contains query word
|
|
203
|
+
if (queryWords.some(w => fileLower.includes(w))) score += 2;
|
|
204
|
+
|
|
205
|
+
// Doc contains query word
|
|
206
|
+
if (symbol.doc && queryWords.some(w => symbol.doc!.toLowerCase().includes(w))) score += 4;
|
|
207
|
+
|
|
208
|
+
// Boost exported symbols
|
|
209
|
+
if (symbol.exported) score += 1;
|
|
210
|
+
|
|
211
|
+
// Boost functions/classes over imports
|
|
212
|
+
if (symbol.kind === "function" || symbol.kind === "class") score += 1;
|
|
213
|
+
|
|
214
|
+
// Semantic matching for common patterns
|
|
215
|
+
if (queryLower.includes("component") && symbol.kind === "component") score += 5;
|
|
216
|
+
if (queryLower.includes("hook") && symbol.kind === "hook") score += 5;
|
|
217
|
+
if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface")) score += 5;
|
|
218
|
+
if (queryLower.includes("import") && symbol.kind === "import") score += 5;
|
|
219
|
+
if (queryLower.includes("class") && symbol.kind === "class") score += 5;
|
|
220
|
+
|
|
221
|
+
return { symbol, score };
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Sort by score, filter zero scores
|
|
225
|
+
const results = scored
|
|
226
|
+
.filter(s => s.score > 0)
|
|
227
|
+
.sort((a, b) => b.score - a.score)
|
|
228
|
+
.slice(0, maxResults)
|
|
229
|
+
.map(s => s.symbol);
|
|
230
|
+
|
|
231
|
+
// Make file paths relative
|
|
232
|
+
for (const r of results) {
|
|
233
|
+
if (r.file.startsWith(cwd)) {
|
|
234
|
+
r.file = "." + r.file.slice(cwd.length);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Estimate token savings
|
|
239
|
+
const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
|
|
240
|
+
const rawTokens = Math.ceil(rawGrep.length / 4);
|
|
241
|
+
const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
query,
|
|
245
|
+
symbols: results,
|
|
246
|
+
totalFiles: files.length,
|
|
247
|
+
tokensSaved: Math.max(0, rawTokens - resultTokens),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Quick helper: find all exported functions */
|
|
252
|
+
export async function findExports(cwd: string): Promise<CodeSymbol[]> {
|
|
253
|
+
const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
|
|
254
|
+
return result.symbols;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Quick helper: find all React components */
|
|
258
|
+
export async function findComponents(cwd: string): Promise<CodeSymbol[]> {
|
|
259
|
+
const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
|
|
260
|
+
return result.symbols;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Quick helper: find all hooks */
|
|
264
|
+
export async function findHooks(cwd: string): Promise<CodeSymbol[]> {
|
|
265
|
+
const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
|
|
266
|
+
return result.symbols;
|
|
267
|
+
}
|
package/src/smart-display.ts
CHANGED
|
@@ -89,6 +89,11 @@ function collapseNodeModules(paths: string[]): { nodeModulesPaths: string[]; oth
|
|
|
89
89
|
/** Smart display: compress file path output into grouped patterns */
|
|
90
90
|
export function smartDisplay(lines: string[]): string[] {
|
|
91
91
|
if (lines.length <= 5) return lines;
|
|
92
|
+
|
|
93
|
+
// Try ls -la table compression first
|
|
94
|
+
const lsCompressed = compressLsTable(lines);
|
|
95
|
+
if (lsCompressed) return lsCompressed;
|
|
96
|
+
|
|
92
97
|
if (!looksLikePaths(lines)) return compressGeneric(lines);
|
|
93
98
|
|
|
94
99
|
const paths = lines.map(l => l.trim()).filter(l => l);
|
|
@@ -127,7 +132,9 @@ export function smartDisplay(lines: string[]): string[] {
|
|
|
127
132
|
const sorted = files.sort();
|
|
128
133
|
const pattern = findPattern(sorted);
|
|
129
134
|
if (pattern) {
|
|
130
|
-
|
|
135
|
+
const dateRange = collapseDateRange(sorted);
|
|
136
|
+
const rangeStr = dateRange ? ` (${dateRange})` : "";
|
|
137
|
+
result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
|
|
131
138
|
} else {
|
|
132
139
|
result.push(` ${dir}/ (${files.length} files)`);
|
|
133
140
|
// Show first 2 + count
|
|
@@ -163,6 +170,95 @@ export function smartDisplay(lines: string[]): string[] {
|
|
|
163
170
|
return result;
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
/** Detect date range in timestamps and collapse */
|
|
174
|
+
function collapseDateRange(files: string[]): string | null {
|
|
175
|
+
const timestamps: Date[] = [];
|
|
176
|
+
for (const f of files) {
|
|
177
|
+
const match = f.match(/(\d{4})-(\d{2})-(\d{2})T?(\d{2})?/);
|
|
178
|
+
if (match) {
|
|
179
|
+
const [, y, m, d, h] = match;
|
|
180
|
+
timestamps.push(new Date(`${y}-${m}-${d}T${h ?? "00"}:00:00`));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (timestamps.length < 2) return null;
|
|
184
|
+
timestamps.sort((a, b) => a.getTime() - b.getTime());
|
|
185
|
+
const first = timestamps[0];
|
|
186
|
+
const last = timestamps[timestamps.length - 1];
|
|
187
|
+
const fmt = (d: Date) => `${d.getMonth() + 1}/${d.getDate()}`;
|
|
188
|
+
if (first.toDateString() === last.toDateString()) {
|
|
189
|
+
return `${fmt(first)}`;
|
|
190
|
+
}
|
|
191
|
+
return `${fmt(first)}–${fmt(last)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Detect and compress ls -la style table output */
|
|
195
|
+
function compressLsTable(lines: string[]): string[] | null {
|
|
196
|
+
// Detect ls -la format: permissions size date name
|
|
197
|
+
const lsPattern = /^[dlcbps-][rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+\S+\s+\w+\s+\d+\s+[\d:]+\s+.+$/;
|
|
198
|
+
const isLsOutput = lines.filter(l => lsPattern.test(l.trim())).length > lines.length * 0.5;
|
|
199
|
+
if (!isLsOutput) return null;
|
|
200
|
+
|
|
201
|
+
const result: string[] = [];
|
|
202
|
+
const dirs: string[] = [];
|
|
203
|
+
const files: { name: string; size: string }[] = [];
|
|
204
|
+
let totalSize = 0;
|
|
205
|
+
|
|
206
|
+
for (const line of lines) {
|
|
207
|
+
const match = line.trim().match(/^([dlcbps-])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\S+)\s+\w+\s+\d+\s+[\d:]+\s+(.+)$/);
|
|
208
|
+
if (!match) {
|
|
209
|
+
if (line.trim().startsWith("total ")) continue;
|
|
210
|
+
result.push(line);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const [, type, sizeStr, name] = match;
|
|
215
|
+
const size = parseInt(sizeStr) || 0;
|
|
216
|
+
totalSize += size;
|
|
217
|
+
|
|
218
|
+
if (type === "d") {
|
|
219
|
+
dirs.push(name);
|
|
220
|
+
} else {
|
|
221
|
+
files.push({ name, size: formatSize(size) });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Compact display
|
|
226
|
+
if (dirs.length > 0) {
|
|
227
|
+
result.push(` 📁 ${dirs.join(" ")}${dirs.length > 5 ? ` (+${dirs.length - 5} more)` : ""}`);
|
|
228
|
+
}
|
|
229
|
+
if (files.length <= 8) {
|
|
230
|
+
for (const f of files) {
|
|
231
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// Show top 5 by size + count
|
|
235
|
+
const sorted = files.sort((a, b) => parseSize(b.size) - parseSize(a.size));
|
|
236
|
+
for (const f of sorted.slice(0, 5)) {
|
|
237
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
238
|
+
}
|
|
239
|
+
result.push(` ... +${files.length - 5} more files (${formatSize(totalSize)} total)`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatSize(bytes: number): string {
|
|
246
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}M`;
|
|
247
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}K`;
|
|
248
|
+
return `${bytes}B`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseSize(s: string): number {
|
|
252
|
+
const match = s.match(/([\d.]+)([BKMG])?/);
|
|
253
|
+
if (!match) return 0;
|
|
254
|
+
const n = parseFloat(match[1]);
|
|
255
|
+
const unit = match[2];
|
|
256
|
+
if (unit === "K") return n * 1000;
|
|
257
|
+
if (unit === "M") return n * 1000000;
|
|
258
|
+
if (unit === "G") return n * 1000000000;
|
|
259
|
+
return n;
|
|
260
|
+
}
|
|
261
|
+
|
|
166
262
|
/** Compress non-path generic output by deduplicating similar lines */
|
|
167
263
|
function compressGeneric(lines: string[]): string[] {
|
|
168
264
|
if (lines.length <= 10) return lines;
|