@hasna/terminal 0.3.1 → 0.5.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,271 @@
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
+ export function extractSymbolsFromFile(filePath: string): CodeSymbol[] {
37
+ return extractSymbols(filePath);
38
+ }
39
+
40
+ function extractSymbols(filePath: string): CodeSymbol[] {
41
+ if (!existsSync(filePath)) return [];
42
+ const content = readFileSync(filePath, "utf8");
43
+ const lines = content.split("\n");
44
+ const symbols: CodeSymbol[] = [];
45
+ const file = filePath;
46
+
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const line = lines[i];
49
+ const lineNum = i + 1;
50
+ const isExported = line.trimStart().startsWith("export");
51
+
52
+ // Functions: export function X(...) or export const X = (...) =>
53
+ const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
54
+ if (funcMatch) {
55
+ const prevLine = i > 0 ? lines[i - 1] : "";
56
+ const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
57
+ ? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
58
+ : undefined;
59
+ symbols.push({
60
+ name: funcMatch[1], kind: "function", file, line: lineNum,
61
+ signature: line.trim().replace(/\{.*$/, "").trim(),
62
+ exported: isExported, doc,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ // Arrow functions: export const X = (...) =>
68
+ const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
69
+ if (arrowMatch) {
70
+ // Detect React hooks
71
+ const isHook = arrowMatch[1].startsWith("use");
72
+ const isComponent = /^[A-Z]/.test(arrowMatch[1]);
73
+ symbols.push({
74
+ name: arrowMatch[1],
75
+ kind: isHook ? "hook" : isComponent ? "component" : "function",
76
+ file, line: lineNum,
77
+ signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
78
+ exported: isExported,
79
+ });
80
+ continue;
81
+ }
82
+
83
+ // Classes
84
+ const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
85
+ if (classMatch) {
86
+ symbols.push({
87
+ name: classMatch[1], kind: "class", file, line: lineNum,
88
+ signature: line.trim().replace(/\{.*$/, "").trim(),
89
+ exported: isExported,
90
+ });
91
+ continue;
92
+ }
93
+
94
+ // Interfaces
95
+ const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
96
+ if (ifaceMatch) {
97
+ symbols.push({
98
+ name: ifaceMatch[1], kind: "interface", file, line: lineNum,
99
+ signature: line.trim().replace(/\{.*$/, "").trim(),
100
+ exported: isExported,
101
+ });
102
+ continue;
103
+ }
104
+
105
+ // Type aliases
106
+ const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
107
+ if (typeMatch) {
108
+ symbols.push({
109
+ name: typeMatch[1], kind: "type", file, line: lineNum,
110
+ signature: line.trim(),
111
+ exported: isExported,
112
+ });
113
+ continue;
114
+ }
115
+
116
+ // Imports (for dependency tracking)
117
+ const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
118
+ if (importMatch) {
119
+ const names = importMatch[1]
120
+ ? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
121
+ : [importMatch[2]];
122
+ for (const name of names) {
123
+ if (name) {
124
+ symbols.push({
125
+ name, kind: "import", file, line: lineNum,
126
+ signature: `from '${importMatch[3]}'`,
127
+ exported: false,
128
+ });
129
+ }
130
+ }
131
+ continue;
132
+ }
133
+
134
+ // Exported constants/variables
135
+ const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
136
+ if (constMatch && !arrowMatch) {
137
+ symbols.push({
138
+ name: constMatch[1], kind: "variable", file, line: lineNum,
139
+ signature: line.trim().slice(0, 80),
140
+ exported: true,
141
+ });
142
+ }
143
+ }
144
+
145
+ return symbols;
146
+ }
147
+
148
+ /** Find all source files in a directory */
149
+ async function findSourceFiles(cwd: string, maxFiles: number = 200): Promise<string[]> {
150
+ const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
151
+ const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
152
+ const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
153
+ const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
154
+ const output = await exec(cmd, cwd);
155
+ return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
156
+ }
157
+
158
+ /** Semantic search: find symbols matching a natural language query */
159
+ export async function semanticSearch(
160
+ query: string,
161
+ cwd: string,
162
+ options: { kinds?: CodeSymbol["kind"][]; exportedOnly?: boolean; maxResults?: number } = {}
163
+ ): Promise<SemanticSearchResult> {
164
+ const { kinds, exportedOnly = false, maxResults = 30 } = options;
165
+
166
+ // Find all source files
167
+ const files = await findSourceFiles(cwd);
168
+
169
+ // Extract symbols from all files
170
+ let allSymbols: CodeSymbol[] = [];
171
+ for (const file of files) {
172
+ try {
173
+ allSymbols.push(...extractSymbols(file));
174
+ } catch { /* skip unreadable files */ }
175
+ }
176
+
177
+ // Filter by kind
178
+ if (kinds) {
179
+ allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
180
+ }
181
+
182
+ // Filter by exported
183
+ if (exportedOnly) {
184
+ allSymbols = allSymbols.filter(s => s.exported);
185
+ }
186
+
187
+ // Score each symbol against the query
188
+ const queryLower = query.toLowerCase();
189
+ const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
190
+
191
+ const scored = allSymbols.map(symbol => {
192
+ let score = 0;
193
+ const nameLower = symbol.name.toLowerCase();
194
+ const sigLower = (symbol.signature ?? "").toLowerCase();
195
+ const fileLower = symbol.file.toLowerCase();
196
+
197
+ // Exact name match
198
+ if (queryWords.some(w => nameLower === w)) score += 10;
199
+
200
+ // Name contains query word
201
+ if (queryWords.some(w => nameLower.includes(w))) score += 5;
202
+
203
+ // Signature contains query word
204
+ if (queryWords.some(w => sigLower.includes(w))) score += 3;
205
+
206
+ // File path contains query word
207
+ if (queryWords.some(w => fileLower.includes(w))) score += 2;
208
+
209
+ // Doc contains query word
210
+ if (symbol.doc && queryWords.some(w => symbol.doc!.toLowerCase().includes(w))) score += 4;
211
+
212
+ // Boost exported symbols
213
+ if (symbol.exported) score += 1;
214
+
215
+ // Boost functions/classes over imports
216
+ if (symbol.kind === "function" || symbol.kind === "class") score += 1;
217
+
218
+ // Semantic matching for common patterns
219
+ if (queryLower.includes("component") && symbol.kind === "component") score += 5;
220
+ if (queryLower.includes("hook") && symbol.kind === "hook") score += 5;
221
+ if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface")) score += 5;
222
+ if (queryLower.includes("import") && symbol.kind === "import") score += 5;
223
+ if (queryLower.includes("class") && symbol.kind === "class") score += 5;
224
+
225
+ return { symbol, score };
226
+ });
227
+
228
+ // Sort by score, filter zero scores
229
+ const results = scored
230
+ .filter(s => s.score > 0)
231
+ .sort((a, b) => b.score - a.score)
232
+ .slice(0, maxResults)
233
+ .map(s => s.symbol);
234
+
235
+ // Make file paths relative
236
+ for (const r of results) {
237
+ if (r.file.startsWith(cwd)) {
238
+ r.file = "." + r.file.slice(cwd.length);
239
+ }
240
+ }
241
+
242
+ // Estimate token savings
243
+ const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
244
+ const rawTokens = Math.ceil(rawGrep.length / 4);
245
+ const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
246
+
247
+ return {
248
+ query,
249
+ symbols: results,
250
+ totalFiles: files.length,
251
+ tokensSaved: Math.max(0, rawTokens - resultTokens),
252
+ };
253
+ }
254
+
255
+ /** Quick helper: find all exported functions */
256
+ export async function findExports(cwd: string): Promise<CodeSymbol[]> {
257
+ const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
258
+ return result.symbols;
259
+ }
260
+
261
+ /** Quick helper: find all React components */
262
+ export async function findComponents(cwd: string): Promise<CodeSymbol[]> {
263
+ const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
264
+ return result.symbols;
265
+ }
266
+
267
+ /** Quick helper: find all hooks */
268
+ export async function findHooks(cwd: string): Promise<CodeSymbol[]> {
269
+ const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
270
+ return result.symbols;
271
+ }
@@ -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
- result.push(` ${dir}/${pattern} ×${files.length}`);
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;