@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.
package/dist/App.js CHANGED
@@ -11,6 +11,7 @@ import Spinner from "./Spinner.js";
11
11
  import Browse from "./Browse.js";
12
12
  import FuzzyPicker from "./FuzzyPicker.js";
13
13
  import { createSession, logInteraction, updateInteraction } from "./sessions-db.js";
14
+ import { smartDisplay } from "./smart-display.js";
14
15
  loadCache();
15
16
  const MAX_LINES = 20;
16
17
  // ── helpers ───────────────────────────────────────────────────────────────────
@@ -84,9 +85,11 @@ export default function App() {
84
85
  };
85
86
  const pushScroll = (entry) => updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
86
87
  const commitStream = (nl, cmd, lines, error) => {
87
- const truncated = lines.length > MAX_LINES;
88
88
  const filePaths = !error ? extractFilePaths(lines) : [];
89
- // Build short output summary for session context (first 10 lines)
89
+ // Smart display: compress repetitive output (paths, duplicates, patterns)
90
+ const displayLines = !error && lines.length > 5 ? smartDisplay(lines) : lines;
91
+ const truncated = displayLines.length > MAX_LINES;
92
+ // Build short output summary for session context (first 10 lines of ORIGINAL output)
90
93
  const shortOutput = lines.slice(0, 10).join("\n") + (lines.length > 10 ? `\n... (${lines.length} lines total)` : "");
91
94
  const entry = { nl, cmd, output: shortOutput, error: error || undefined };
92
95
  updateTab(t => ({
@@ -95,7 +98,7 @@ export default function App() {
95
98
  sessionEntries: [...t.sessionEntries.slice(-9), entry],
96
99
  scroll: [...t.scroll, {
97
100
  nl, cmd,
98
- lines: truncated ? lines.slice(0, MAX_LINES) : lines,
101
+ lines: truncated ? displayLines.slice(0, MAX_LINES) : displayLines,
99
102
  truncated, expanded: false,
100
103
  error: error || undefined,
101
104
  filePaths: filePaths.length ? filePaths : undefined,
@@ -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"),
@@ -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
+ }
@@ -0,0 +1,286 @@
1
+ // Smart output display — compress repetitive output into grouped patterns
2
+ import { dirname, basename } from "path";
3
+ /** Detect if lines look like file paths */
4
+ function looksLikePaths(lines) {
5
+ if (lines.length < 3)
6
+ return false;
7
+ const pathLike = lines.filter(l => l.trim().match(/^\.?\//) || l.trim().includes("/"));
8
+ return pathLike.length > lines.length * 0.6;
9
+ }
10
+ /** Find the varying part between similar strings and create a glob pattern */
11
+ function findPattern(items) {
12
+ if (items.length < 2)
13
+ return null;
14
+ const first = items[0];
15
+ const last = items[items.length - 1];
16
+ // Find common prefix
17
+ let prefixLen = 0;
18
+ while (prefixLen < first.length && prefixLen < last.length && first[prefixLen] === last[prefixLen]) {
19
+ prefixLen++;
20
+ }
21
+ // Find common suffix
22
+ let suffixLen = 0;
23
+ while (suffixLen < first.length - prefixLen &&
24
+ suffixLen < last.length - prefixLen &&
25
+ first[first.length - 1 - suffixLen] === last[last.length - 1 - suffixLen]) {
26
+ suffixLen++;
27
+ }
28
+ const prefix = first.slice(0, prefixLen);
29
+ const suffix = suffixLen > 0 ? first.slice(-suffixLen) : "";
30
+ if (prefix.length + suffix.length < first.length * 0.3)
31
+ return null; // too different
32
+ return `${prefix}*${suffix}`;
33
+ }
34
+ /** Group file paths by directory */
35
+ function groupByDir(paths) {
36
+ const groups = new Map();
37
+ for (const p of paths) {
38
+ const dir = dirname(p.trim());
39
+ const file = basename(p.trim());
40
+ if (!groups.has(dir))
41
+ groups.set(dir, []);
42
+ groups.get(dir).push(file);
43
+ }
44
+ return groups;
45
+ }
46
+ /** Detect duplicate filenames across directories */
47
+ function findDuplicates(paths) {
48
+ const byName = new Map();
49
+ for (const p of paths) {
50
+ const file = basename(p.trim());
51
+ if (!byName.has(file))
52
+ byName.set(file, []);
53
+ byName.get(file).push(dirname(p.trim()));
54
+ }
55
+ // Only return files that appear in 2+ dirs
56
+ const dupes = new Map();
57
+ for (const [file, dirs] of byName) {
58
+ if (dirs.length >= 2)
59
+ dupes.set(file, dirs);
60
+ }
61
+ return dupes;
62
+ }
63
+ /** Collapse node_modules paths */
64
+ function collapseNodeModules(paths) {
65
+ const nodeModulesPaths = [];
66
+ const otherPaths = [];
67
+ for (const p of paths) {
68
+ if (p.includes("node_modules")) {
69
+ nodeModulesPaths.push(p);
70
+ }
71
+ else {
72
+ otherPaths.push(p);
73
+ }
74
+ }
75
+ return { nodeModulesPaths, otherPaths };
76
+ }
77
+ /** Smart display: compress file path output into grouped patterns */
78
+ export function smartDisplay(lines) {
79
+ if (lines.length <= 5)
80
+ return lines;
81
+ // Try ls -la table compression first
82
+ const lsCompressed = compressLsTable(lines);
83
+ if (lsCompressed)
84
+ return lsCompressed;
85
+ if (!looksLikePaths(lines))
86
+ return compressGeneric(lines);
87
+ const paths = lines.map(l => l.trim()).filter(l => l);
88
+ const result = [];
89
+ // Step 1: Separate node_modules
90
+ const { nodeModulesPaths, otherPaths } = collapseNodeModules(paths);
91
+ // Step 2: Find duplicates in non-node_modules paths
92
+ const dupes = findDuplicates(otherPaths);
93
+ const handledPaths = new Set();
94
+ // Show duplicates first
95
+ for (const [file, dirs] of dupes) {
96
+ if (dirs.length >= 3) {
97
+ result.push(` **/${file} ×${dirs.length}`);
98
+ result.push(` ${dirs.slice(0, 5).join(", ")}${dirs.length > 5 ? ` +${dirs.length - 5} more` : ""}`);
99
+ for (const d of dirs) {
100
+ handledPaths.add(`${d}/${file}`);
101
+ }
102
+ }
103
+ }
104
+ // Step 3: Group remaining by directory
105
+ const remaining = otherPaths.filter(p => !handledPaths.has(p.trim()));
106
+ const dirGroups = groupByDir(remaining);
107
+ for (const [dir, files] of dirGroups) {
108
+ if (files.length === 1) {
109
+ result.push(` ${dir}/${files[0]}`);
110
+ }
111
+ else if (files.length <= 3) {
112
+ result.push(` ${dir}/`);
113
+ for (const f of files)
114
+ result.push(` ${f}`);
115
+ }
116
+ else {
117
+ // Try to find a pattern
118
+ const sorted = files.sort();
119
+ const pattern = findPattern(sorted);
120
+ if (pattern) {
121
+ const dateRange = collapseDateRange(sorted);
122
+ const rangeStr = dateRange ? ` (${dateRange})` : "";
123
+ result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
124
+ }
125
+ else {
126
+ result.push(` ${dir}/ (${files.length} files)`);
127
+ // Show first 2 + count
128
+ result.push(` ${sorted[0]}, ${sorted[1]}${files.length > 2 ? `, +${files.length - 2} more` : ""}`);
129
+ }
130
+ }
131
+ }
132
+ // Step 4: Collapsed node_modules summary
133
+ if (nodeModulesPaths.length > 0) {
134
+ if (nodeModulesPaths.length <= 2) {
135
+ for (const p of nodeModulesPaths)
136
+ result.push(` ${p}`);
137
+ }
138
+ else {
139
+ // Group node_modules by package name
140
+ const nmGroups = new Map();
141
+ for (const p of nodeModulesPaths) {
142
+ // Extract package name from path: ./X/node_modules/PKG/...
143
+ const match = p.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
144
+ const pkg = match ? match[1] : "other";
145
+ nmGroups.set(pkg, (nmGroups.get(pkg) ?? 0) + 1);
146
+ }
147
+ result.push(` node_modules/ (${nodeModulesPaths.length} matches)`);
148
+ const topPkgs = [...nmGroups.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
149
+ for (const [pkg, count] of topPkgs) {
150
+ result.push(` ${pkg} ×${count}`);
151
+ }
152
+ if (nmGroups.size > 3) {
153
+ result.push(` +${nmGroups.size - 3} more packages`);
154
+ }
155
+ }
156
+ }
157
+ return result;
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
+ }
249
+ /** Compress non-path generic output by deduplicating similar lines */
250
+ function compressGeneric(lines) {
251
+ if (lines.length <= 10)
252
+ return lines;
253
+ const result = [];
254
+ let repeatCount = 0;
255
+ let lastPattern = "";
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const line = lines[i];
258
+ // Normalize: remove numbers, timestamps, hashes for pattern matching
259
+ const pattern = line
260
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.-]+Z?/g, "TIMESTAMP")
261
+ .replace(/\b[0-9a-f]{7,40}\b/g, "HASH")
262
+ .replace(/\b\d+\b/g, "N")
263
+ .trim();
264
+ if (pattern === lastPattern && i > 0) {
265
+ repeatCount++;
266
+ }
267
+ else {
268
+ if (repeatCount > 1) {
269
+ result.push(` ... ×${repeatCount} similar`);
270
+ }
271
+ else if (repeatCount === 1) {
272
+ result.push(lines[i - 1]);
273
+ }
274
+ result.push(line);
275
+ lastPattern = pattern;
276
+ repeatCount = 0;
277
+ }
278
+ }
279
+ if (repeatCount > 1) {
280
+ result.push(` ... ×${repeatCount} similar`);
281
+ }
282
+ else if (repeatCount === 1) {
283
+ result.push(lines[lines.length - 1]);
284
+ }
285
+ return result;
286
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.3.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.0.0"
38
+ "typescript": "^5.9.3"
38
39
  }
39
40
  }
package/src/App.tsx CHANGED
@@ -10,6 +10,7 @@ import Spinner from "./Spinner.js";
10
10
  import Browse from "./Browse.js";
11
11
  import FuzzyPicker from "./FuzzyPicker.js";
12
12
  import { createSession, endSession, logInteraction, updateInteraction } from "./sessions-db.js";
13
+ import { smartDisplay } from "./smart-display.js";
13
14
 
14
15
  loadCache();
15
16
 
@@ -134,9 +135,11 @@ export default function App() {
134
135
  updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
135
136
 
136
137
  const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
137
- const truncated = lines.length > MAX_LINES;
138
138
  const filePaths = !error ? extractFilePaths(lines) : [];
139
- // Build short output summary for session context (first 10 lines)
139
+ // Smart display: compress repetitive output (paths, duplicates, patterns)
140
+ const displayLines = !error && lines.length > 5 ? smartDisplay(lines) : lines;
141
+ const truncated = displayLines.length > MAX_LINES;
142
+ // Build short output summary for session context (first 10 lines of ORIGINAL output)
140
143
  const shortOutput = lines.slice(0, 10).join("\n") + (lines.length > 10 ? `\n... (${lines.length} lines total)` : "");
141
144
  const entry: SessionEntry = { nl, cmd, output: shortOutput, error: error || undefined };
142
145
  updateTab(t => ({
@@ -145,7 +148,7 @@ export default function App() {
145
148
  sessionEntries: [...t.sessionEntries.slice(-9), entry],
146
149
  scroll: [...t.scroll, {
147
150
  nl, cmd,
148
- lines: truncated ? lines.slice(0, MAX_LINES) : lines,
151
+ lines: truncated ? displayLines.slice(0, MAX_LINES) : displayLines,
149
152
  truncated, expanded: false,
150
153
  error: error || undefined,
151
154
  filePaths: filePaths.length ? filePaths : undefined,
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(
@@ -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";