@hasna/terminal 0.3.0 → 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.
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { smartDisplay } from "./smart-display.js";
3
+
4
+ describe("smartDisplay", () => {
5
+ it("passes through short output unchanged", () => {
6
+ const lines = ["file1.txt", "file2.txt", "file3.txt"];
7
+ expect(smartDisplay(lines)).toEqual(lines);
8
+ });
9
+
10
+ it("collapses node_modules paths", () => {
11
+ const lines = [
12
+ "./src/app.ts",
13
+ "./node_modules/foo/index.js",
14
+ "./node_modules/bar/index.js",
15
+ "./node_modules/baz/index.js",
16
+ "./node_modules/qux/index.js",
17
+ "./node_modules/quux/index.js",
18
+ "./tests/app.test.ts",
19
+ ];
20
+ const result = smartDisplay(lines);
21
+ expect(result.length).toBeLessThanOrEqual(lines.length);
22
+ expect(result.some(l => l.includes("node_modules/") && l.includes("matches"))).toBe(true);
23
+ });
24
+
25
+ it("groups files by directory", () => {
26
+ const lines = [
27
+ "./src/components/Button.tsx",
28
+ "./src/components/Modal.tsx",
29
+ "./src/components/Input.tsx",
30
+ "./src/components/Select.tsx",
31
+ "./src/components/Table.tsx",
32
+ "./src/lib/utils.ts",
33
+ ];
34
+ const result = smartDisplay(lines);
35
+ expect(result.length).toBeLessThan(lines.length);
36
+ });
37
+
38
+ it("detects duplicate filenames across directories", () => {
39
+ const lines = [
40
+ "./open-testers/node_modules/zod/.github/logo.png",
41
+ "./open-attachments/node_modules/zod/.github/logo.png",
42
+ "./open-terminal/node_modules/zod/.github/logo.png",
43
+ "./open-emails/node_modules/zod/.github/logo.png",
44
+ "./src/app.ts",
45
+ "./tests/app.test.ts",
46
+ ];
47
+ const result = smartDisplay(lines);
48
+ // Should collapse the 4 identical logo.png into one entry
49
+ expect(result.length).toBeLessThan(lines.length);
50
+ });
51
+
52
+ it("collapses timestamp-like patterns", () => {
53
+ const lines = [
54
+ "./screenshots/page-2026-03-09T05-43-19-525Z.png",
55
+ "./screenshots/page-2026-03-09T05-43-30-441Z.png",
56
+ "./screenshots/page-2026-03-09T05-48-20-401Z.png",
57
+ "./screenshots/page-2026-03-09T05-58-25-884Z.png",
58
+ "./screenshots/page-2026-03-10T05-30-07-086Z.png",
59
+ "./screenshots/page-2026-03-10T05-32-31-790Z.png",
60
+ "./screenshots/page-2026-03-10T13-37-04-963Z.png",
61
+ ];
62
+ const result = smartDisplay(lines);
63
+ expect(result.length).toBeLessThan(lines.length);
64
+ // Should show pattern like page-*.png ×7
65
+ expect(result.some(l => l.includes("×"))).toBe(true);
66
+ });
67
+
68
+ it("handles the exact user example", () => {
69
+ const lines = [
70
+ "./open-testers/node_modules/playwright-core/lib/server/chromium/appIcon.png",
71
+ "./open-testers/node_modules/zod-to-json-schema/.github/CR_logotype-full-color.png",
72
+ "./open-attachments/node_modules/zod-to-json-schema/.github/CR_logotype-full-color.png",
73
+ "./open-attachments/dashboard/src/assets/hero.png",
74
+ "./open-terminal/node_modules/zod-to-json-schema/.github/CR_logotype-full-color.png",
75
+ "./open-emails/node_modules/zod-to-json-schema/.github/CR_logotype-full-color.png",
76
+ "./open-todos/node_modules/zod-to-json-schema/.github/CR_logotype-full-color.png",
77
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-43-19-525Z.png",
78
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-43-30-441Z.png",
79
+ "./open-todos/.playwright-mcp/page-2026-03-09T06-01-53-897Z.png",
80
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-58-25-884Z.png",
81
+ "./open-todos/.playwright-mcp/page-2026-03-10T05-30-07-086Z.png",
82
+ "./open-todos/.playwright-mcp/page-2026-03-09T08-38-38-240Z.png",
83
+ "./open-todos/.playwright-mcp/page-2026-03-10T13-37-04-963Z.png",
84
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-40-31-213Z.png",
85
+ "./open-todos/.playwright-mcp/page-2026-03-10T05-32-31-790Z.png",
86
+ "./open-todos/.playwright-mcp/page-2026-03-09T08-38-26-591Z.png",
87
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-48-20-401Z.png",
88
+ "./open-todos/.playwright-mcp/page-2026-03-09T08-38-16-511Z.png",
89
+ "./open-todos/.playwright-mcp/page-2026-03-09T05-34-10-009Z.png",
90
+ ];
91
+ const result = smartDisplay(lines);
92
+ console.log("User example output:");
93
+ for (const line of result) console.log(line);
94
+ console.log(`\nCompressed: ${lines.length} → ${result.length} lines`);
95
+ expect(result.length).toBeLessThan(lines.length);
96
+ });
97
+ });
@@ -0,0 +1,300 @@
1
+ // Smart output display — compress repetitive output into grouped patterns
2
+
3
+ import { dirname, basename } from "path";
4
+
5
+ interface GroupedEntry {
6
+ type: "single" | "pattern" | "duplicate" | "collapsed";
7
+ display: string;
8
+ }
9
+
10
+ /** Detect if lines look like file paths */
11
+ function looksLikePaths(lines: string[]): boolean {
12
+ if (lines.length < 3) return false;
13
+ const pathLike = lines.filter(l => l.trim().match(/^\.?\//) || l.trim().includes("/"));
14
+ return pathLike.length > lines.length * 0.6;
15
+ }
16
+
17
+ /** Find the varying part between similar strings and create a glob pattern */
18
+ function findPattern(items: string[]): string | null {
19
+ if (items.length < 2) return null;
20
+ const first = items[0];
21
+ const last = items[items.length - 1];
22
+
23
+ // Find common prefix
24
+ let prefixLen = 0;
25
+ while (prefixLen < first.length && prefixLen < last.length && first[prefixLen] === last[prefixLen]) {
26
+ prefixLen++;
27
+ }
28
+
29
+ // Find common suffix
30
+ let suffixLen = 0;
31
+ while (
32
+ suffixLen < first.length - prefixLen &&
33
+ suffixLen < last.length - prefixLen &&
34
+ first[first.length - 1 - suffixLen] === last[last.length - 1 - suffixLen]
35
+ ) {
36
+ suffixLen++;
37
+ }
38
+
39
+ const prefix = first.slice(0, prefixLen);
40
+ const suffix = suffixLen > 0 ? first.slice(-suffixLen) : "";
41
+
42
+ if (prefix.length + suffix.length < first.length * 0.3) return null; // too different
43
+
44
+ return `${prefix}*${suffix}`;
45
+ }
46
+
47
+ /** Group file paths by directory */
48
+ function groupByDir(paths: string[]): Map<string, string[]> {
49
+ const groups = new Map<string, string[]>();
50
+ for (const p of paths) {
51
+ const dir = dirname(p.trim());
52
+ const file = basename(p.trim());
53
+ if (!groups.has(dir)) groups.set(dir, []);
54
+ groups.get(dir)!.push(file);
55
+ }
56
+ return groups;
57
+ }
58
+
59
+ /** Detect duplicate filenames across directories */
60
+ function findDuplicates(paths: string[]): Map<string, string[]> {
61
+ const byName = new Map<string, string[]>();
62
+ for (const p of paths) {
63
+ const file = basename(p.trim());
64
+ if (!byName.has(file)) byName.set(file, []);
65
+ byName.get(file)!.push(dirname(p.trim()));
66
+ }
67
+ // Only return files that appear in 2+ dirs
68
+ const dupes = new Map<string, string[]>();
69
+ for (const [file, dirs] of byName) {
70
+ if (dirs.length >= 2) dupes.set(file, dirs);
71
+ }
72
+ return dupes;
73
+ }
74
+
75
+ /** Collapse node_modules paths */
76
+ function collapseNodeModules(paths: string[]): { nodeModulesPaths: string[]; otherPaths: string[] } {
77
+ const nodeModulesPaths: string[] = [];
78
+ const otherPaths: string[] = [];
79
+ for (const p of paths) {
80
+ if (p.includes("node_modules")) {
81
+ nodeModulesPaths.push(p);
82
+ } else {
83
+ otherPaths.push(p);
84
+ }
85
+ }
86
+ return { nodeModulesPaths, otherPaths };
87
+ }
88
+
89
+ /** Smart display: compress file path output into grouped patterns */
90
+ export function smartDisplay(lines: string[]): string[] {
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
+
97
+ if (!looksLikePaths(lines)) return compressGeneric(lines);
98
+
99
+ const paths = lines.map(l => l.trim()).filter(l => l);
100
+ const result: string[] = [];
101
+
102
+ // Step 1: Separate node_modules
103
+ const { nodeModulesPaths, otherPaths } = collapseNodeModules(paths);
104
+
105
+ // Step 2: Find duplicates in non-node_modules paths
106
+ const dupes = findDuplicates(otherPaths);
107
+ const handledPaths = new Set<string>();
108
+
109
+ // Show duplicates first
110
+ for (const [file, dirs] of dupes) {
111
+ if (dirs.length >= 3) {
112
+ result.push(` **/${file} ×${dirs.length}`);
113
+ result.push(` ${dirs.slice(0, 5).join(", ")}${dirs.length > 5 ? ` +${dirs.length - 5} more` : ""}`);
114
+ for (const d of dirs) {
115
+ handledPaths.add(`${d}/${file}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ // Step 3: Group remaining by directory
121
+ const remaining = otherPaths.filter(p => !handledPaths.has(p.trim()));
122
+ const dirGroups = groupByDir(remaining);
123
+
124
+ for (const [dir, files] of dirGroups) {
125
+ if (files.length === 1) {
126
+ result.push(` ${dir}/${files[0]}`);
127
+ } else if (files.length <= 3) {
128
+ result.push(` ${dir}/`);
129
+ for (const f of files) result.push(` ${f}`);
130
+ } else {
131
+ // Try to find a pattern
132
+ const sorted = files.sort();
133
+ const pattern = findPattern(sorted);
134
+ if (pattern) {
135
+ const dateRange = collapseDateRange(sorted);
136
+ const rangeStr = dateRange ? ` (${dateRange})` : "";
137
+ result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
138
+ } else {
139
+ result.push(` ${dir}/ (${files.length} files)`);
140
+ // Show first 2 + count
141
+ result.push(` ${sorted[0]}, ${sorted[1]}${files.length > 2 ? `, +${files.length - 2} more` : ""}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Step 4: Collapsed node_modules summary
147
+ if (nodeModulesPaths.length > 0) {
148
+ if (nodeModulesPaths.length <= 2) {
149
+ for (const p of nodeModulesPaths) result.push(` ${p}`);
150
+ } else {
151
+ // Group node_modules by package name
152
+ const nmGroups = new Map<string, number>();
153
+ for (const p of nodeModulesPaths) {
154
+ // Extract package name from path: ./X/node_modules/PKG/...
155
+ const match = p.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
156
+ const pkg = match ? match[1] : "other";
157
+ nmGroups.set(pkg, (nmGroups.get(pkg) ?? 0) + 1);
158
+ }
159
+ result.push(` node_modules/ (${nodeModulesPaths.length} matches)`);
160
+ const topPkgs = [...nmGroups.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
161
+ for (const [pkg, count] of topPkgs) {
162
+ result.push(` ${pkg} ×${count}`);
163
+ }
164
+ if (nmGroups.size > 3) {
165
+ result.push(` +${nmGroups.size - 3} more packages`);
166
+ }
167
+ }
168
+ }
169
+
170
+ return result;
171
+ }
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
+
262
+ /** Compress non-path generic output by deduplicating similar lines */
263
+ function compressGeneric(lines: string[]): string[] {
264
+ if (lines.length <= 10) return lines;
265
+
266
+ const result: string[] = [];
267
+ let repeatCount = 0;
268
+ let lastPattern = "";
269
+
270
+ for (let i = 0; i < lines.length; i++) {
271
+ const line = lines[i];
272
+ // Normalize: remove numbers, timestamps, hashes for pattern matching
273
+ const pattern = line
274
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.-]+Z?/g, "TIMESTAMP")
275
+ .replace(/\b[0-9a-f]{7,40}\b/g, "HASH")
276
+ .replace(/\b\d+\b/g, "N")
277
+ .trim();
278
+
279
+ if (pattern === lastPattern && i > 0) {
280
+ repeatCount++;
281
+ } else {
282
+ if (repeatCount > 1) {
283
+ result.push(` ... ×${repeatCount} similar`);
284
+ } else if (repeatCount === 1) {
285
+ result.push(lines[i - 1]);
286
+ }
287
+ result.push(line);
288
+ lastPattern = pattern;
289
+ repeatCount = 0;
290
+ }
291
+ }
292
+
293
+ if (repeatCount > 1) {
294
+ result.push(` ... ×${repeatCount} similar`);
295
+ } else if (repeatCount === 1) {
296
+ result.push(lines[lines.length - 1]);
297
+ }
298
+
299
+ return result;
300
+ }