decorated-pi 0.2.1 → 0.3.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.
@@ -1,175 +1,381 @@
1
1
  /**
2
2
  * Smart @ - 高速文件搜索自动补全
3
3
  *
4
+ * 设计:
5
+ * 1. 缓存生命周期绑定用户交互: 触发 @ 时收集+缓存, 选择/取消时清除
6
+ * 2. 收集时预计算路径惩罚(路径固有,跟 query 无关)
7
+ * 3. 惩罚分级消费: 每个文件只命中最高级别的一档惩罚,不叠加
8
+ * 4. 搜索时只算匹配分, 总分 = 匹配分 + 惩罚分
9
+ *
10
+ * 候选收集:
11
+ * - Git 仓库: git ls-files 列出文件, 从文件路径推导目录
12
+ * - 非 Git 仓库: fd 列出文件和目录
13
+ *
14
+ * 惩罚来源:
15
+ * - Git 仓库: .gitignore 规则(动态) + 静态规则
16
+ * - 非 Git 仓库: 仅静态规则
17
+ *
18
+ * 惩罚分级(从高到低,首次命中即消费,不叠加):
19
+ * Tier 1 (-400): 匹配 .gitignore 规则(仅 git 仓库)
20
+ * Tier 2 (-300): 在 .* 或 __* 目录下
21
+ * Tier 3 (-200): 在已知噪音目录下(build/dist/coverage 等)
22
+ * Tier 4 (-150~-80): 坏扩展名(二进制/编译产物/媒体文件)
23
+ * Base (always): -depth*2 - name.length
24
+ *
25
+ * 匹配:
26
+ * - 大小写敏感
27
+ * - 目录优先
28
+ *
4
29
  * 【重要】applyCompletion / shouldTriggerFileCompletion 必须用 .bind(orig)
5
- * 不能用箭头函数!Pi editor 会做原型检查,新函数导致扩展崩溃。
30
+ * 不能用箭头函数! Pi editor 会做原型检查,新函数导致扩展崩溃。
6
31
  */
7
32
 
8
33
  import { spawnSync } from "child_process";
34
+ import { existsSync } from "node:fs";
35
+ import { join } from "node:path";
9
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
37
 
11
38
  // ═══════════════════════════════════════════════════════════
12
- // 文件列表(缓存 10s,maxBuffer 防 ENOBUFS)
39
+ // 类型
40
+ // ═══════════════════════════════════════════════════════════
41
+
42
+ type PenaltyTier = 0 | 1 | 2 | 3 | 4;
43
+
44
+ interface FileCandidate {
45
+ path: string; // 相对路径 "extensions/lsp/tools.ts"
46
+ name: string; // 文件名 "tools.ts"
47
+ isDir: boolean;
48
+ tier: PenaltyTier; // 语义分级,用于空查询过滤
49
+ penalty: number; // 预计算: tierPenalty + basePenalty
50
+ }
51
+
52
+ // ═══════════════════════════════════════════════════════════
53
+ // 硬排除 & 惩罚规则
13
54
  // ═══════════════════════════════════════════════════════════
14
55
 
15
- let cachedDirs: string[] = [];
16
- let cachedFiles: string[] = [];
17
- let cacheTime = 0;
18
- let cacheCwd = "";
56
+ /** 硬排除: 不枚举其子文件/子目录,但目录本身保留为候选 */
57
+ const HARD_EXCLUDE_DIRS = new Set(["node_modules", ".git", ".pnpm", ".svn"]);
58
+
59
+ /** Tier 3: 已知噪音目录(不以 . 开头,但通常是构建产物) */
60
+ const BAD_DIRS = new Set(["build", "dist", "coverage", "out", "target"]);
61
+
62
+ /** Tier 4: 扩展名惩罚(值为负数) */
63
+ const EXT_PENALTY: Record<string, number> = {
64
+ o: -150, obj: -150, a: -150, so: -150, dll: -150, exe: -150,
65
+ wasm: -150, class: -120, pyc: -120,
66
+ bmp: -100, png: -100, jpg: -100, gif: -100, ico: -100, svg: -80,
67
+ mp3: -100, wav: -100, mp4: -100, avi: -100,
68
+ pdf: -100, zip: -100, tar: -100, gz: -100,
69
+ lock: -80,
70
+ };
71
+
72
+ // ═══════════════════════════════════════════════════════════
73
+ // 惩罚预计算(分级消费,首次命中即停)
74
+ // ═══════════════════════════════════════════════════════════
75
+
76
+ interface PenaltyMeta {
77
+ tier: PenaltyTier;
78
+ penalty: number;
79
+ }
80
+
81
+ function computePenaltyMeta(filePath: string, isDir: boolean, gitIgnored: boolean): PenaltyMeta {
82
+ const parts = filePath.replace(/\/$/, "").split("/");
83
+ const name = parts[parts.length - 1] || filePath;
84
+ const ext = (!isDir && name.includes(".")) ? (name.split(".").pop()?.toLowerCase() || "") : "";
85
+ const depth = parts.length;
19
86
 
20
- function getFileAndDirList(cwd: string): { dirs: string[]; files: string[] } {
21
- const now = Date.now();
22
- if (cacheCwd === cwd && now - cacheTime < 10000) return { dirs: cachedDirs, files: cachedFiles };
87
+ // 文件: 检查父目录段; 目录: 检查所有段(含自身)
88
+ const dirSegments = isDir ? parts : parts.slice(0, -1);
89
+
90
+ let tier: PenaltyTier = 0;
91
+ let tierPenalty = 0;
92
+
93
+ if (gitIgnored) {
94
+ tier = 1;
95
+ tierPenalty = -400;
96
+ } else if (dirSegments.some(d => d.startsWith(".") || d.startsWith("__"))) {
97
+ tier = 2;
98
+ tierPenalty = -300;
99
+ } else if (dirSegments.some(d => BAD_DIRS.has(d))) {
100
+ tier = 3;
101
+ tierPenalty = -200;
102
+ } else if (!isDir && (EXT_PENALTY[ext] ?? 0) < 0) {
103
+ tier = 4;
104
+ tierPenalty = EXT_PENALTY[ext]!;
105
+ }
106
+
107
+ const basePenalty = -(depth * 20) - name.length;
108
+ return { tier, penalty: tierPenalty + basePenalty };
109
+ }
110
+
111
+ function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean): number {
112
+ return computePenaltyMeta(filePath, isDir, gitIgnored).penalty;
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════
116
+ // 候选收集
117
+ // ═══════════════════════════════════════════════════════════
118
+
119
+ const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
120
+
121
+ function collectCandidates(cwd: string): FileCandidate[] {
122
+ const candidates: FileCandidate[] = [];
123
+
124
+ // 判断路径是否为硬排除目录的子项(不含硬排除目录本身)
125
+ const isChildOfHardExclude = (p: string): boolean => {
126
+ const parts = p.replace(/\/$/, "").split("/");
127
+ for (let i = 0; i < parts.length - 1; i++) {
128
+ if (HARD_EXCLUDE_DIRS.has(parts[i]!)) return true;
129
+ }
130
+ return false;
131
+ };
132
+
133
+ const opts = { ...SPAWN_OPTS, cwd };
134
+
135
+ // ── 检测是否 git 仓库 ──
136
+ const isGit = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], opts).status === 0;
137
+
138
+ if (isGit) {
139
+ collectGit(candidates, opts, isChildOfHardExclude);
140
+ } else {
141
+ collectFd(candidates, cwd, isChildOfHardExclude);
142
+ }
143
+
144
+ return candidates;
145
+ }
23
146
 
24
- let dirs: string[] = [];
25
- let files: string[] = [];
26
- const opts = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024, cwd };
147
+ /** Git 仓库: git ls-files 列出文件, 从文件路径推导目录 */
148
+ function collectGit(
149
+ candidates: FileCandidate[],
150
+ opts: { timeout: number; encoding: "utf-8"; maxBuffer: number; cwd: string },
151
+ isChildOfHardExclude: (p: string) => boolean,
152
+ ) {
153
+ // 可见文件(tracked + untracked 非 ignored)
154
+ const r1 = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
155
+ const visibleFiles = (r1.status === 0 && r1.stdout)
156
+ ? r1.stdout.trim().split("\n").filter(Boolean)
157
+ : [];
27
158
 
28
- // 去掉 cwd 前缀和 ./ 前缀,转相对路径
159
+ // ignored 项(目录优先聚合)
160
+ const r2 = spawnSync(
161
+ "git",
162
+ ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"],
163
+ opts,
164
+ );
165
+ const ignoredDirs = new Set<string>();
166
+ const ignoredFiles: string[] = [];
167
+ if (r2.status === 0 && r2.stdout) {
168
+ for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
169
+ const entry = raw.replace(/^\.\//, "");
170
+ if (isChildOfHardExclude(entry)) continue;
171
+ if (entry.endsWith("/")) {
172
+ ignoredDirs.add(entry.replace(/\/$/, ""));
173
+ } else {
174
+ ignoredFiles.push(entry);
175
+ }
176
+ }
177
+ }
178
+
179
+ const hasIgnoredAncestor = (p: string): boolean => {
180
+ const parts = p.split("/");
181
+ let current = "";
182
+ for (let i = 0; i < parts.length - 1; i++) {
183
+ current = current ? `${current}/${parts[i]}` : parts[i]!;
184
+ if (ignoredDirs.has(current)) return true;
185
+ }
186
+ return false;
187
+ };
188
+
189
+ const ignoredFileSet = new Set(ignoredFiles);
190
+
191
+ // ── 可见文件 → 候选 ──
192
+ const dirSet = new Set<string>();
193
+ for (const f of visibleFiles) {
194
+ if (isChildOfHardExclude(f)) continue;
195
+ const name = f.split("/").pop() || f;
196
+ const meta = computePenaltyMeta(f, false, false);
197
+ candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
198
+ // 推导目录
199
+ const parts = f.split("/");
200
+ let current = "";
201
+ for (let i = 0; i < parts.length - 1; i++) {
202
+ current = current ? `${current}/${parts[i]}` : parts[i]!;
203
+ dirSet.add(current);
204
+ }
205
+ }
206
+
207
+ // ── ignored 文件 → 候选 ──
208
+ for (const f of ignoredFiles) {
209
+ const name = f.split("/").pop() || f;
210
+ const meta = computePenaltyMeta(f, false, true);
211
+ candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
212
+ }
213
+
214
+ // ── 推导的可见目录 → 候选 ──
215
+ for (const d of dirSet) {
216
+ const name = d.split("/").pop() || d;
217
+ const meta = computePenaltyMeta(d, true, false);
218
+ candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
219
+ }
220
+
221
+ // ── ignored 目录 → 候选 ──
222
+ for (const d of ignoredDirs) {
223
+ const name = d.split("/").pop() || d;
224
+ const meta = computePenaltyMeta(d, true, true);
225
+ candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
226
+ }
227
+
228
+ // ── 硬排除目录本身 → 候选(仅存在时加入) ──
229
+ for (const hd of HARD_EXCLUDE_DIRS) {
230
+ if (!existsSync(join(opts.cwd, hd))) continue;
231
+ const meta = computePenaltyMeta(hd, true, false);
232
+ candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
233
+ }
234
+ }
235
+
236
+ /** 非 Git 仓库: fd 列出文件和目录, 仅用静态规则惩罚 */
237
+ function collectFd(
238
+ candidates: FileCandidate[],
239
+ cwd: string,
240
+ isChildOfHardExclude: (p: string) => boolean,
241
+ ) {
29
242
  const rel = (s: string) => {
30
243
  let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
31
244
  return r.startsWith("./") ? r.slice(2) : r;
32
245
  };
33
246
 
34
- // 1. git ls-files (returns relative paths)
35
- const git = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], opts);
36
- if (git.status === 0) {
37
- const r = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
38
- if (r.status === 0 && r.stdout) files = r.stdout.trim().split("\n").filter(Boolean);
247
+ const fdExcludes = [...HARD_EXCLUDE_DIRS].flatMap(d => ["--exclude", d]);
248
+ const fdOpts = { ...SPAWN_OPTS, cwd: undefined as string | undefined };
249
+
250
+ // 文件(排除硬排除目录的子文件)
251
+ const r1 = spawnSync("fd", ["--type", "f", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
252
+ if (r1.status === 0 && r1.stdout) {
253
+ for (const raw of r1.stdout.trim().split("\n").filter(Boolean)) {
254
+ const f = rel(raw);
255
+ const name = f.split("/").pop() || f;
256
+ const meta = computePenaltyMeta(f, false, false);
257
+ candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
258
+ }
39
259
  }
40
260
 
41
- // 2. fd (returns absolute paths when given absolute path)
42
- if (!files.length) {
43
- const fdFiles = spawnSync("fd", ["--type", "f", "--hidden", ".", cwd], { ...opts, cwd: undefined });
44
- if (fdFiles.status === 0 && fdFiles.stdout) files = fdFiles.stdout.trim().split("\n").filter(Boolean).map(rel);
261
+ // 目录(排除硬排除目录的子目录)
262
+ const r2 = spawnSync("fd", ["--type", "d", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
263
+ if (r2.status === 0 && r2.stdout) {
264
+ for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
265
+ const d = rel(raw).replace(/\/$/, "");
266
+ const name = d.split("/").pop() || d;
267
+ const meta = computePenaltyMeta(d, true, false);
268
+ candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
269
+ }
45
270
  }
46
- const fdDirs = spawnSync("fd", ["--type", "d", "--hidden", ".", cwd], { ...opts, cwd: undefined });
47
- if (fdDirs.status === 0 && fdDirs.stdout) dirs = fdDirs.stdout.trim().split("\n").filter(Boolean).map(rel);
48
271
 
49
- cachedDirs = dirs;
50
- cachedFiles = files;
51
- cacheTime = Date.now();
52
- cacheCwd = cwd;
53
- return { dirs, files };
272
+ // 硬排除目录本身 → 候选(仅存在时加入)
273
+ for (const hd of HARD_EXCLUDE_DIRS) {
274
+ if (!existsSync(join(cwd, hd))) continue;
275
+ const meta = computePenaltyMeta(hd, true, false);
276
+ candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
277
+ }
54
278
  }
55
279
 
56
280
  // ═══════════════════════════════════════════════════════════
57
- // 评分
281
+ // 匹配评分(query 相关,大小写敏感)
58
282
  // ═══════════════════════════════════════════════════════════
59
283
 
60
- const EXT_PENALTY: Record<string, number> = {
61
- o: -500, obj: -500, a: -500, so: -500, dll: -500, exe: -500,
62
- wasm: -500, class: -400, pyc: -400,
63
- bmp: -200, png: -200, jpg: -200, gif: -200, ico: -200, svg: -100,
64
- mp3: -200, wav: -200, mp4: -200, avi: -200,
65
- pdf: -200, zip: -200, tar: -200, gz: -200,
66
- lock: -100, json: 0, yml: 0, yaml: 0, toml: 0,
67
- };
68
-
69
- const BAD_DIRS = ["node_modules", ".obj", "build", "dist"];
70
-
71
- // 真模糊匹配(字符可以不连续)
284
+ /** 模糊匹配(大小写敏感,字符可以不连续) */
72
285
  function fuzzyScore(text: string, query: string): number {
73
- const t = text.toLowerCase(), q = query.toLowerCase();
74
286
  let qi = 0, firstMatch = -1, lastMatch = -1;
75
- for (let ti = 0; ti < t.length && qi < q.length; ti++) {
76
- if (t[ti] === q[qi]) {
287
+ for (let ti = 0; ti < text.length && qi < query.length; ti++) {
288
+ if (text[ti] === query[qi]) {
77
289
  if (firstMatch < 0) firstMatch = ti;
78
290
  lastMatch = ti;
79
291
  qi++;
80
292
  }
81
293
  }
82
- if (qi < q.length) return 0;
83
- const span = lastMatch - firstMatch + 1; // 匹配跨度
84
- const totalLen = t.length;
85
- // 跨度小 + 文件名短 = 高分
86
- return Math.max(10, 200 - span * 3 - totalLen);
294
+ if (qi < query.length) return 0;
295
+ const span = lastMatch - firstMatch + 1;
296
+ return Math.max(10, 200 - span * 3 - text.length);
87
297
  }
88
298
 
89
- function scoreFile(file: string, query: string, isDir = false): number {
90
- const cleaned = isDir ? file.replace(/\/$/, "") : file;
91
- const parts = cleaned.split("/");
92
- const name = parts[parts.length - 1] || cleaned;
299
+ /** 计算单个候选对 query 的匹配分(大小写敏感) */
300
+ function computeMatchScore(candidate: FileCandidate, query: string): number {
301
+ const { path: filePath, name, isDir } = candidate;
93
302
  const stem = name.replace(/\.[^.]+$/, "");
94
- const ext = name.includes(".") ? name.split(".").pop()?.toLowerCase() || "" : "";
95
- const q = query.toLowerCase();
96
- const nl = name.toLowerCase();
97
- const sl = stem.toLowerCase();
98
- const depth = parts.length;
99
- const inDir = parts.slice(0, -1).some((d) => d.toLowerCase().includes(q));
303
+ const parts = filePath.replace(/\/$/, "").split("/");
304
+ const inDir = parts.slice(0, -1).some(d => d.includes(query));
100
305
 
101
306
  let s = 0;
102
- if (sl === q) s = isDir ? 980 : 950;
103
- else if (nl.startsWith(q + ".") || nl.startsWith(q + "_") || nl.startsWith(q + "/")) s = 900;
104
- else if (nl.startsWith(q)) s = 800;
105
- else if (nl.includes(q)) s = 500;
106
- else if (file.toLowerCase().includes(q)) s = 100;
107
- else {
108
- // 模糊匹配仅限文件名(全路径太松)
109
- s = fuzzyScore(nl, q);
110
- }
307
+
308
+ // 大小写敏感匹配
309
+ if (stem === query) s = isDir ? 1500 : 1200;
310
+ else if (name.startsWith(query + ".") || name.startsWith(query + "_")) s = 1000;
311
+ else if (name.startsWith(query)) s = 900;
312
+ else if (name.includes(query)) s = 600;
313
+ else if (filePath.includes(query)) s = 300;
314
+ else s = fuzzyScore(name, query);
315
+
111
316
  if (!s) return 0;
112
317
 
113
- // 目录加成(+500,确保匹配目录排第一)
114
- if (isDir) s += 500;
115
- // 扩展名奖惩
116
- if (!isDir) s += EXT_PENALTY[ext] ?? 0;
117
- // 隐藏目录 / 缓存目录 / __pycache__ 类目录 降权
118
- const inBadDir = parts.some((d) => d.startsWith(".") || d.startsWith("__") || BAD_DIRS.includes(d));
119
- if (inBadDir) s -= 200;
120
- if (inDir) s += 300;
318
+ // 目录轻微加成(tiebreaker,不碾压深度)
319
+ if (isDir) s += 50;
320
+
321
+ // 父目录命中加成
322
+ if (inDir) s += 250;
121
323
 
122
- return s * 3 - name.length - depth * 2;
324
+ return s;
123
325
  }
124
326
 
125
327
  // ═══════════════════════════════════════════════════════════
126
- // 搜索
328
+ // 搜索(匹配分 + 惩罚分 = 总分排序)
127
329
  // ═══════════════════════════════════════════════════════════
128
330
 
129
- function smartSearch(dirs: string[], files: string[], query: string): string[] {
331
+ function smartSearch(candidates: FileCandidate[], query: string): string[] {
332
+ // 空查询: 按 tier 语义过滤, 隐藏 Tier 1/2
130
333
  if (!query) {
131
- // 无查询:排除隐藏目录和隐藏目录下的所有文件
132
- const isHidden = (p: string) => p.split("/").some((s) => s.startsWith(".") || s.startsWith("__"));
133
- return [
134
- ...dirs.filter((d) => !isHidden(d)).slice(0, 10),
135
- ...files.filter((f) => !isHidden(f)).slice(0, 10),
136
- ];
334
+ const visible = candidates.filter(c => c.tier === 0 || c.tier === 3 || c.tier === 4);
335
+ return visible
336
+ .sort((a, b) => {
337
+ // 目录优先
338
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
339
+ return b.penalty - a.penalty || a.path.localeCompare(b.path);
340
+ })
341
+ .slice(0, 20)
342
+ .map(c => c.path);
137
343
  }
138
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
139
344
 
140
- // 合并打分(隐藏目录降权但可见)
141
- const scored = [
142
- ...dirs.map((d) => ({ path: d.endsWith("/") ? d : d + "/", s: scoreFile(d, tokens[0]!, true) })),
143
- ...files.map((f) => ({ path: f, s: scoreFile(f, tokens[0]!, false) })),
144
- ].filter((x) => x.s > 0);
345
+ const tokens = query.split(/\s+/).filter(Boolean);
145
346
 
146
347
  if (tokens.length === 1) {
348
+ const scored = candidates
349
+ .map(c => {
350
+ const matchScore = computeMatchScore(c, tokens[0]!);
351
+ return { path: c.path, total: matchScore + c.penalty, matchScore };
352
+ })
353
+ .filter(x => x.matchScore > 0);
354
+
147
355
  return scored
148
- .sort((a, b) => b.s - a.s || a.path.localeCompare(b.path))
356
+ .sort((a, b) => b.total - a.total || a.path.localeCompare(b.path))
149
357
  .slice(0, 20)
150
- .map((x) => x.path);
358
+ .map(x => x.path);
151
359
  }
152
360
 
153
- // 多词 OR
361
+ // 多词: 各 token 独立搜索取 union
154
362
  const seen = new Set<string>();
155
- const all = [
156
- ...dirs.map((d) => ({ path: d.endsWith("/") ? d : d + "/", isDir: true })),
157
- ...files.map((f) => ({ path: f, isDir: false })),
158
- ];
159
363
  for (const t of tokens) {
160
- for (const { path: p } of all
161
- .map(({ path, isDir }) => ({ path, s: scoreFile(path, t, isDir) }))
162
- .filter((x) => x.s > 0)
163
- .sort((a, b) => b.s - a.s)
164
- .slice(0, 20)) {
165
- seen.add(p);
166
- }
364
+ const scored = candidates
365
+ .map(c => {
366
+ const matchScore = computeMatchScore(c, t);
367
+ return { path: c.path, total: matchScore + c.penalty, matchScore };
368
+ })
369
+ .filter(x => x.matchScore > 0)
370
+ .sort((a, b) => b.total - a.total)
371
+ .slice(0, 20);
372
+ for (const { path } of scored) seen.add(path);
167
373
  }
168
374
  return [...seen].slice(0, 20);
169
375
  }
170
376
 
171
377
  // ═══════════════════════════════════════════════════════════
172
- // @ 前缀
378
+ // @ 前缀检测
173
379
  // ═══════════════════════════════════════════════════════════
174
380
 
175
381
  function atPrefix(text: string): string | null {
@@ -182,6 +388,16 @@ function atPrefix(text: string): string | null {
182
388
  return null;
183
389
  }
184
390
 
391
+ // 测试导出: 仅暴露纯逻辑
392
+ export const __smartAtTest = {
393
+ computePenaltyMeta,
394
+ computePenalty,
395
+ fuzzyScore,
396
+ computeMatchScore,
397
+ smartSearch,
398
+ atPrefix,
399
+ };
400
+
185
401
  // ═══════════════════════════════════════════════════════════
186
402
  // 入口
187
403
  // ═══════════════════════════════════════════════════════════
@@ -190,23 +406,35 @@ export function setupSmartAt(pi: ExtensionAPI) {
190
406
  pi.on("session_start", (_e: any, ctx: any) => {
191
407
  const cwd = String(ctx.cwd || "").trim();
192
408
 
409
+ let cache: FileCandidate[] | null = null;
410
+
411
+ function getOrBuildCache(): FileCandidate[] {
412
+ if (!cache) cache = collectCandidates(cwd);
413
+ return cache;
414
+ }
415
+
416
+ function clearCache() {
417
+ cache = null;
418
+ }
419
+
193
420
  ctx.ui.addAutocompleteProvider((orig: any) => ({
194
421
  getSuggestions: (lines: any, cl: any, cc: any, opts: any) => {
195
422
  const prefix = atPrefix((lines[cl] || "").slice(0, cc));
196
423
  if (!prefix) {
424
+ clearCache();
197
425
  ctx.ui.setWidget("smart-at", undefined);
198
426
  return orig.getSuggestions(lines, cl, cc, opts);
199
427
  }
200
428
 
201
- const { dirs, files } = getFileAndDirList(cwd);
202
- const results = smartSearch(dirs, files, prefix.slice(1));
429
+ const candidates = getOrBuildCache();
430
+ const results = smartSearch(candidates, prefix.slice(1));
203
431
 
204
432
  if (!results.length) {
205
433
  ctx.ui.setWidget("smart-at", undefined);
206
434
  return null;
207
435
  }
208
436
 
209
- ctx.ui.setWidget("smart-at", ["powered by decorated-pi"]);
437
+ ctx.ui.setWidget("smart-at", ["\x1b[2mpowered by decorated-pi\x1b[0m"]);
210
438
  return Promise.resolve({
211
439
  items: results.map((f: string) => ({
212
440
  value: "@" + f,
@@ -216,8 +444,8 @@ export function setupSmartAt(pi: ExtensionAPI) {
216
444
  prefix,
217
445
  });
218
446
  },
219
- // ⚠️ 必须 .bind(orig)
220
447
  applyCompletion: (...args: any[]) => {
448
+ clearCache();
221
449
  ctx.ui.setWidget("smart-at", undefined);
222
450
  return orig.applyCompletion.apply(orig, args);
223
451
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.2.1",
4
- "description": "Essential utilities for pi: safety gates, secret redaction, smart @ completion, dynamic AGENTS loading, image fallback, and LSP tools",
3
+ "version": "0.3.0",
4
+ "description": "A pi extension with better work-flow: patch tool, safety gates, secret redaction, smart @ completion, dynamic AGENTS loading, image fallback, and LSP tools",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",