@narumitw/pi-files 0.0.0-stage → 0.1.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -2
  4. package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
  5. package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
  6. package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
  7. package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
  8. package/dist/chunks/chunk-F5QFC255.ts +101 -0
  9. package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
  10. package/dist/chunks/chunk-NFNGJETD.ts +77 -0
  11. package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
  12. package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
  13. package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
  14. package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
  15. package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
  16. package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
  17. package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
  18. package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
  19. package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
  20. package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
  21. package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
  22. package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
  23. package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
  24. package/dist/chunks/menu-P6IBMROH.ts +876 -0
  25. package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
  26. package/dist/index.ts +1789 -0
  27. package/dist/index.ts.map +7 -0
  28. package/docs/edit-scope-reference.md +142 -0
  29. package/docs/file-context-reference.md +91 -0
  30. package/docs/migration.md +105 -0
  31. package/docs/verification.md +99 -0
  32. package/package.json +58 -4
  33. package/src/content-search-session.ts +204 -0
  34. package/src/content-search-ui.ts +159 -0
  35. package/src/content-search.ts +170 -0
  36. package/src/context-service.ts +515 -0
  37. package/src/external-editor.ts +143 -0
  38. package/src/file-browser-ui.ts +128 -0
  39. package/src/file-browser.ts +76 -0
  40. package/src/file-context-explorer.ts +992 -0
  41. package/src/file-context-menu.ts +360 -0
  42. package/src/file-context-preview-ui.ts +354 -0
  43. package/src/file-context-settings.ts +301 -0
  44. package/src/file-context.ts +321 -0
  45. package/src/file-search.ts +246 -0
  46. package/src/files-menu.ts +125 -0
  47. package/src/files-settings.ts +314 -0
  48. package/src/files-widget.ts +65 -0
  49. package/src/files.ts +148 -0
  50. package/src/git-context.ts +485 -0
  51. package/src/index.ts +1 -0
  52. package/src/scope/contracts.ts +243 -0
  53. package/src/scope/display.ts +27 -0
  54. package/src/scope/edit-scope.ts +435 -0
  55. package/src/scope/files.ts +118 -0
  56. package/src/scope/input.ts +207 -0
  57. package/src/scope/menu.ts +277 -0
  58. package/src/scope/paths.ts +142 -0
  59. package/src/scope/picker-keys.ts +154 -0
  60. package/src/scope/picker.ts +484 -0
  61. package/src/scope/policy.ts +55 -0
  62. package/src/scope/settings.ts +138 -0
  63. package/src/scope/state.ts +91 -0
  64. package/src/scope/ui.ts +106 -0
@@ -0,0 +1,321 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+ import type { GitFileContext, GitProjectInfo, GitRevisionFile } from "./git-context.js";
6
+
7
+ const DEFAULT_MAX_FILES = 5_000;
8
+ const DEFAULT_MAX_BYTES = 1_000_000;
9
+ export const MAX_QUOTE_BYTES = 50_000;
10
+ export const MAX_QUOTE_LINES = 500;
11
+ export const MAX_PENDING_QUOTES = 8;
12
+ export const MAX_PENDING_QUOTE_BYTES = 100_000;
13
+ const IGNORED_DIRECTORIES = new Set([
14
+ ".git",
15
+ ".hg",
16
+ ".svn",
17
+ ".next",
18
+ "build",
19
+ "coverage",
20
+ "dist",
21
+ "node_modules",
22
+ "target",
23
+ ]);
24
+
25
+ export interface FileQuoteGitProvenance {
26
+ head: string;
27
+ branch?: string;
28
+ status?: string;
29
+ revision?: string;
30
+ blob?: string;
31
+ contentSha256: string;
32
+ source?: "worktree" | "revision" | "git_diff";
33
+ base?: string;
34
+ }
35
+
36
+ export type FileQuoteGitProvenanceInput = Omit<FileQuoteGitProvenance, "contentSha256">;
37
+
38
+ export interface FileQuote {
39
+ path: string;
40
+ startLine: number;
41
+ endLine: number;
42
+ text: string;
43
+ git?: FileQuoteGitProvenance;
44
+ }
45
+
46
+ export interface LoadedProjectTextFile {
47
+ path: string;
48
+ lines: string[];
49
+ }
50
+
51
+ interface DiscoveryOptions {
52
+ maxFiles?: number;
53
+ signal?: AbortSignal;
54
+ }
55
+
56
+ interface LoadOptions {
57
+ maxBytes?: number;
58
+ beforeOpen?: () => Promise<void>;
59
+ signal?: AbortSignal;
60
+ }
61
+
62
+ export async function discoverProjectFiles(root: string, options: DiscoveryOptions = {}): Promise<string[]> {
63
+ const maxFiles = Math.max(1, options.maxFiles ?? DEFAULT_MAX_FILES);
64
+ const files: string[] = [];
65
+ options.signal?.throwIfAborted();
66
+
67
+ const canonicalRoot = await realpath(root);
68
+ const directories: Array<{ directory: string; prefix: string }> = [{ directory: canonicalRoot, prefix: "" }];
69
+ options.signal?.throwIfAborted();
70
+
71
+ for (let index = 0; index < directories.length && files.length < maxFiles; index += 1) {
72
+ options.signal?.throwIfAborted();
73
+ const current = directories[index];
74
+ if (!current) break;
75
+ const { directory, prefix } = current;
76
+ const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
77
+ compareStrings(left.name, right.name),
78
+ );
79
+ options.signal?.throwIfAborted();
80
+ for (const entry of entries) {
81
+ options.signal?.throwIfAborted();
82
+ if (IGNORED_DIRECTORIES.has(entry.name) || entry.isSymbolicLink()) continue;
83
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
84
+ const absolutePath = resolve(directory, entry.name);
85
+ if (entry.isDirectory()) {
86
+ directories.push({ directory: absolutePath, prefix: relativePath });
87
+ } else if (entry.isFile()) {
88
+ files.push(relativePath);
89
+ if (files.length >= maxFiles) break;
90
+ }
91
+ }
92
+ }
93
+ options.signal?.throwIfAborted();
94
+ return files.sort(compareStrings);
95
+ }
96
+
97
+ export async function loadProjectTextFile(
98
+ root: string,
99
+ projectPath: string,
100
+ options: LoadOptions = {},
101
+ ): Promise<LoadedProjectTextFile> {
102
+ options.signal?.throwIfAborted();
103
+ if (!projectPath || isAbsolute(projectPath)) throw new Error("File path is outside the project");
104
+ const canonicalRoot = await realpath(root);
105
+ options.signal?.throwIfAborted();
106
+ const candidate = resolve(canonicalRoot, projectPath);
107
+ if (!isInside(canonicalRoot, candidate)) throw new Error("File path is outside the project");
108
+
109
+ let canonicalFile: string;
110
+ try {
111
+ canonicalFile = await realpath(candidate);
112
+ } catch (error: unknown) {
113
+ throw new Error(`Cannot open ${projectPath}: ${formatError(error)}`);
114
+ }
115
+ options.signal?.throwIfAborted();
116
+ if (!isInside(canonicalRoot, canonicalFile)) throw new Error("File path is outside the project");
117
+ const info = await lstat(canonicalFile);
118
+ options.signal?.throwIfAborted();
119
+ if (!info.isFile()) throw new Error(`${projectPath} is not a regular file`);
120
+
121
+ const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_BYTES);
122
+ if (info.size > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`);
123
+ await options.beforeOpen?.();
124
+ options.signal?.throwIfAborted();
125
+ let file: Awaited<ReturnType<typeof open>>;
126
+ try {
127
+ file = await open(canonicalFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
128
+ } catch (error: unknown) {
129
+ throw new Error(`Cannot safely open ${projectPath}: ${formatError(error)}`);
130
+ }
131
+ try {
132
+ options.signal?.throwIfAborted();
133
+ const openedInfo = await file.stat();
134
+ options.signal?.throwIfAborted();
135
+ if (!openedInfo.isFile()) throw new Error(`${projectPath} is not a regular file`);
136
+ if (openedInfo.dev !== info.dev || openedInfo.ino !== info.ino) {
137
+ throw new Error(`${projectPath} changed while it was being opened safely`);
138
+ }
139
+ if (openedInfo.size > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`);
140
+ const buffer = Buffer.alloc(maxBytes + 1);
141
+ let offset = 0;
142
+ while (offset < buffer.length) {
143
+ options.signal?.throwIfAborted();
144
+ const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
145
+ options.signal?.throwIfAborted();
146
+ if (bytesRead === 0) break;
147
+ offset += bytesRead;
148
+ }
149
+ if (offset > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`);
150
+ const contents = buffer.subarray(0, offset);
151
+ if (contents.includes(0)) throw new Error(`${projectPath} appears to be binary`);
152
+ return {
153
+ path: projectPath.replaceAll("\\", "/"),
154
+ lines: normalizeTextLines(contents.toString("utf8")),
155
+ };
156
+ } finally {
157
+ await file.close();
158
+ }
159
+ }
160
+
161
+ export function createFileQuote(
162
+ path: string,
163
+ lines: readonly string[],
164
+ anchorIndex: number,
165
+ cursorIndex: number,
166
+ git?: FileQuoteGitProvenanceInput,
167
+ ): FileQuote {
168
+ if (lines.length === 0) throw new Error("Cannot quote an empty file");
169
+ const startIndex = Math.max(0, Math.min(anchorIndex, cursorIndex, lines.length - 1));
170
+ const endIndex = Math.max(0, Math.min(Math.max(anchorIndex, cursorIndex), lines.length - 1));
171
+ const text = lines.slice(startIndex, endIndex + 1).join("\n");
172
+ return createFileQuoteSnapshot(path, startIndex + 1, endIndex + 1, text, git);
173
+ }
174
+
175
+ export function createFileQuoteSnapshot(
176
+ path: string,
177
+ startLine: number,
178
+ endLine: number,
179
+ text: string,
180
+ git?: FileQuoteGitProvenanceInput,
181
+ ): FileQuote {
182
+ if (!Number.isSafeInteger(startLine) || !Number.isSafeInteger(endLine) || startLine < 1) {
183
+ throw new Error("Quote lines must be positive integers");
184
+ }
185
+ if (endLine < startLine) throw new Error("Quote end line precedes its start line");
186
+ if (text.split("\n").length > MAX_QUOTE_LINES) {
187
+ throw new Error(`Quote exceeds ${MAX_QUOTE_LINES} lines`);
188
+ }
189
+ if (Buffer.byteLength(text, "utf8") > MAX_QUOTE_BYTES) {
190
+ throw new Error(`Quote exceeds ${MAX_QUOTE_BYTES} bytes`);
191
+ }
192
+ return {
193
+ path,
194
+ startLine,
195
+ endLine,
196
+ text,
197
+ ...(git
198
+ ? {
199
+ git: {
200
+ head: git.head,
201
+ ...(git.branch !== undefined ? { branch: git.branch } : {}),
202
+ ...(git.status !== undefined ? { status: git.status } : {}),
203
+ ...(git.revision !== undefined ? { revision: git.revision } : {}),
204
+ ...(git.blob !== undefined ? { blob: git.blob } : {}),
205
+ contentSha256: createHash("sha256").update(text, "utf8").digest("hex"),
206
+ ...(git.source !== undefined ? { source: git.source } : {}),
207
+ ...(git.base !== undefined ? { base: git.base } : {}),
208
+ },
209
+ }
210
+ : {}),
211
+ };
212
+ }
213
+
214
+ /** Capture the exact worktree or historical preview with selection-time provenance. */
215
+ export function createPreviewQuote(
216
+ file: LoadedProjectTextFile,
217
+ anchor: number,
218
+ cursor: number,
219
+ project?: GitProjectInfo,
220
+ git?: GitFileContext,
221
+ revision?: GitRevisionFile,
222
+ ): FileQuote {
223
+ return createFileQuote(
224
+ file.path,
225
+ file.lines,
226
+ anchor,
227
+ cursor,
228
+ project
229
+ ? {
230
+ head: project.head,
231
+ branch: project.branch,
232
+ status: revision ? "historical" : (git?.status?.label ?? "clean"),
233
+ revision: revision?.revision,
234
+ blob: revision?.blob ?? git?.blob,
235
+ source: revision ? "revision" : "worktree",
236
+ base: revision ? undefined : "HEAD",
237
+ }
238
+ : undefined,
239
+ );
240
+ }
241
+
242
+ export function appendPendingQuote(current: readonly FileQuote[], quote: FileQuote): FileQuote[] {
243
+ if (current.length >= MAX_PENDING_QUOTES) {
244
+ throw new Error(`File Context supports at most ${MAX_PENDING_QUOTES} pending quotes`);
245
+ }
246
+ const totalBytes = [...current, quote].reduce((total, item) => total + Buffer.byteLength(item.text, "utf8"), 0);
247
+ if (totalBytes > MAX_PENDING_QUOTE_BYTES) {
248
+ throw new Error(`Pending quotes exceed ${MAX_PENDING_QUOTE_BYTES} bytes`);
249
+ }
250
+ return [...current, quote];
251
+ }
252
+
253
+ export function formatPromptWithQuote(prompt: string, quote: FileQuote): string {
254
+ return formatPromptWithQuotes(prompt, [quote]);
255
+ }
256
+
257
+ export function formatPromptWithQuotes(prompt: string, quotes: readonly FileQuote[]): string {
258
+ if (quotes.length === 0) return prompt;
259
+ return `${formatQuoteContext(quotes)}\n\n${prompt}`;
260
+ }
261
+
262
+ export function formatQuoteContext(quotes: readonly FileQuote[]): string {
263
+ const blocks = quotes.map((quote) => {
264
+ const path = escapeXml(quote.path);
265
+ const text = escapeXml(quote.text);
266
+ const attributes = [
267
+ `path="${path}"`,
268
+ `lines="${quote.startLine}-${quote.endLine}"`,
269
+ ...formatGitAttributes(quote.git),
270
+ ].join(" ");
271
+ return `<user_file_quote ${attributes}>\n${text}\n</user_file_quote>`;
272
+ });
273
+ const description =
274
+ quotes.length === 1
275
+ ? "The user intentionally selected the file excerpt above."
276
+ : "The user intentionally selected the file excerpts above.";
277
+ return `${blocks.join("\n\n")}\n\n${description}`;
278
+ }
279
+
280
+ function normalizeTextLines(contents: string): string[] {
281
+ if (contents === "") return [];
282
+ const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
283
+ if (lines.at(-1) === "") lines.pop();
284
+ return lines;
285
+ }
286
+
287
+ function compareStrings(left: string, right: string): number {
288
+ return left < right ? -1 : left > right ? 1 : 0;
289
+ }
290
+
291
+ function isInside(root: string, candidate: string): boolean {
292
+ const result = relative(root, candidate);
293
+ return result === "" || (!result.startsWith(`..${sep}`) && result !== ".." && !isAbsolute(result));
294
+ }
295
+
296
+ function formatGitAttributes(git: FileQuoteGitProvenance | undefined): string[] {
297
+ if (!git) return [];
298
+ return [
299
+ ["git_head", git.head],
300
+ ["git_branch", git.branch],
301
+ ["git_status", git.status],
302
+ ["git_revision", git.revision],
303
+ ["git_blob", git.blob],
304
+ ["content_sha256", git.contentSha256],
305
+ ["source", git.source],
306
+ ["git_base", git.base],
307
+ ].flatMap(([name, value]) => (value ? [`${name}="${escapeXml(value)}"`] : []));
308
+ }
309
+
310
+ function escapeXml(value: string): string {
311
+ return value
312
+ .replaceAll("&", "&amp;")
313
+ .replaceAll("<", "&lt;")
314
+ .replaceAll(">", "&gt;")
315
+ .replaceAll('"', "&quot;")
316
+ .replaceAll("'", "&apos;");
317
+ }
318
+
319
+ function formatError(error: unknown): string {
320
+ return error instanceof Error ? error.message : String(error);
321
+ }
@@ -0,0 +1,246 @@
1
+ import { safeTerminalText } from "./file-browser.js";
2
+
3
+ const MAX_SEARCH_QUERY_LENGTH = 256;
4
+ const MAX_SEARCH_QUERY_PARTS = 8;
5
+ const PATH_SCORE_PENALTY = 50;
6
+ const PREFIX_SCORE = 100;
7
+ const SUBSTRING_SCORE = 200;
8
+ const TYPO_SCORE = 300;
9
+ const SUBSEQUENCE_SCORE = 400;
10
+ const TOKENIZED_SCORE = 500;
11
+ const NO_MATCH = Number.POSITIVE_INFINITY;
12
+ const PATH_PART_SEPARATOR = /[/._\-\s]+/u;
13
+
14
+ interface ProjectFileEntry {
15
+ path: string;
16
+ normalizedPath: string;
17
+ normalizedBasename: string;
18
+ basenameParts: readonly string[];
19
+ pathParts: readonly string[];
20
+ originalIndex: number;
21
+ }
22
+
23
+ interface RankedProjectFile {
24
+ path: string;
25
+ score: number;
26
+ originalIndex: number;
27
+ }
28
+
29
+ type TypoDistanceCache = Map<string, Map<string, number | undefined>>;
30
+
31
+ export class ProjectFileSearch {
32
+ private readonly files: readonly string[];
33
+ private readonly entries: readonly ProjectFileEntry[];
34
+
35
+ constructor(files: readonly string[]) {
36
+ this.files = [...files];
37
+ this.entries = this.files.map((path, originalIndex) => {
38
+ const normalizedPath = safeTerminalText(path).toLowerCase();
39
+ const normalizedBasename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
40
+ return {
41
+ path,
42
+ normalizedPath,
43
+ normalizedBasename,
44
+ basenameParts: splitPathParts(normalizedBasename),
45
+ pathParts: splitPathParts(normalizedPath),
46
+ originalIndex,
47
+ };
48
+ });
49
+ }
50
+
51
+ search(query: string): string[] {
52
+ const trimmedQuery = safeTerminalText(query).trim();
53
+ if (!trimmedQuery) return [...this.files];
54
+ if (trimmedQuery.length > MAX_SEARCH_QUERY_LENGTH) return [];
55
+ const normalizedQuery = trimmedQuery.toLowerCase();
56
+ const queryParts = splitPathParts(normalizedQuery);
57
+ const typoDistanceCache: TypoDistanceCache = new Map();
58
+
59
+ return this.entries
60
+ .flatMap((entry): RankedProjectFile[] => {
61
+ const score = scoreEntry(normalizedQuery, queryParts, entry, typoDistanceCache);
62
+ return Number.isFinite(score) ? [{ path: entry.path, score, originalIndex: entry.originalIndex }] : [];
63
+ })
64
+ .sort((left, right) => left.score - right.score || left.originalIndex - right.originalIndex)
65
+ .map((result) => result.path);
66
+ }
67
+ }
68
+
69
+ function scoreEntry(
70
+ query: string,
71
+ queryParts: readonly string[],
72
+ entry: ProjectFileEntry,
73
+ typoDistanceCache: TypoDistanceCache,
74
+ ): number {
75
+ const basenameScore = scoreField(query, queryParts, entry.normalizedBasename, entry.basenameParts, typoDistanceCache);
76
+ const pathScore = scoreField(query, queryParts, entry.normalizedPath, entry.pathParts, typoDistanceCache);
77
+ return Math.min(basenameScore, pathScore + PATH_SCORE_PENALTY);
78
+ }
79
+
80
+ function scoreField(
81
+ query: string,
82
+ queryParts: readonly string[],
83
+ text: string,
84
+ textParts: readonly string[],
85
+ typoDistanceCache: TypoDistanceCache,
86
+ ): number {
87
+ const directScore = scoreText(query, text);
88
+ if (directScore < TYPO_SCORE) return directScore;
89
+ const typoScore = scoreTypo(query, textParts, typoDistanceCache);
90
+ if (Number.isFinite(typoScore)) return Math.min(directScore, typoScore);
91
+ if (Number.isFinite(directScore)) return directScore;
92
+ return scoreTokenized(queryParts, textParts, typoDistanceCache);
93
+ }
94
+
95
+ function scoreTokenized(
96
+ queryParts: readonly string[],
97
+ candidateParts: readonly string[],
98
+ typoDistanceCache: TypoDistanceCache,
99
+ ): number {
100
+ if (queryParts.length < 2 || queryParts.length > MAX_SEARCH_QUERY_PARTS) return NO_MATCH;
101
+
102
+ let totalScore = TOKENIZED_SCORE;
103
+ for (const queryPart of queryParts) {
104
+ let bestDirectScore = NO_MATCH;
105
+ for (let index = 0; index < candidateParts.length; index += 1) {
106
+ bestDirectScore = Math.min(bestDirectScore, scoreText(queryPart, candidateParts[index] ?? "") + index * 2);
107
+ }
108
+ const bestPartScore =
109
+ bestDirectScore < TYPO_SCORE
110
+ ? bestDirectScore
111
+ : Math.min(bestDirectScore, scoreTypo(queryPart, candidateParts, typoDistanceCache));
112
+ if (!Number.isFinite(bestPartScore)) return NO_MATCH;
113
+ totalScore += bestPartScore;
114
+ }
115
+ return totalScore;
116
+ }
117
+
118
+ function scoreText(query: string, text: string): number {
119
+ if (query === text) return 0;
120
+ if (text.startsWith(query)) return PREFIX_SCORE + lengthPenalty(query, text);
121
+
122
+ const substringIndex = text.indexOf(query);
123
+ if (substringIndex >= 0) {
124
+ return SUBSTRING_SCORE + substringIndex * 2 + lengthPenalty(query, text);
125
+ }
126
+
127
+ return scoreSubsequence(query, text);
128
+ }
129
+
130
+ function scoreSubsequence(query: string, text: string): number {
131
+ let queryIndex = 0;
132
+ let firstMatch = -1;
133
+ let previousMatch = -1;
134
+ let gaps = 0;
135
+ let boundaryMatches = 0;
136
+ let consecutiveMatches = 0;
137
+
138
+ for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
139
+ if (text[textIndex] !== query[queryIndex]) continue;
140
+ if (firstMatch < 0) firstMatch = textIndex;
141
+ if (previousMatch >= 0) {
142
+ const gap = textIndex - previousMatch - 1;
143
+ gaps += gap;
144
+ if (gap === 0) consecutiveMatches += 1;
145
+ }
146
+ if (textIndex === 0 || PATH_PART_SEPARATOR.test(text[textIndex - 1] ?? "")) {
147
+ boundaryMatches += 1;
148
+ }
149
+ previousMatch = textIndex;
150
+ queryIndex += 1;
151
+ }
152
+
153
+ if (queryIndex !== query.length) return NO_MATCH;
154
+ return (
155
+ SUBSEQUENCE_SCORE +
156
+ gaps * 3 +
157
+ firstMatch * 2 +
158
+ lengthPenalty(query, text) -
159
+ boundaryMatches * 8 -
160
+ consecutiveMatches
161
+ );
162
+ }
163
+
164
+ function scoreTypo(query: string, parts: readonly string[], typoDistanceCache: TypoDistanceCache): number {
165
+ const maxDistance = allowedTypoDistance(query.length);
166
+ if (maxDistance === 0) return NO_MATCH;
167
+
168
+ let bestScore = NO_MATCH;
169
+ for (let index = 0; index < parts.length; index += 1) {
170
+ const part = parts[index] ?? "";
171
+ if (Math.abs(query.length - part.length) > maxDistance) continue;
172
+ const distance = cachedTypoDistance(query, part, maxDistance, typoDistanceCache);
173
+ if (distance === undefined || distance === 0) continue;
174
+ bestScore = Math.min(bestScore, TYPO_SCORE + distance * 20 + index * 2 + Math.abs(query.length - part.length));
175
+ }
176
+ return bestScore;
177
+ }
178
+
179
+ function cachedTypoDistance(
180
+ query: string,
181
+ part: string,
182
+ maxDistance: number,
183
+ cache: TypoDistanceCache,
184
+ ): number | undefined {
185
+ let queryCache = cache.get(query);
186
+ if (!queryCache) {
187
+ queryCache = new Map();
188
+ cache.set(query, queryCache);
189
+ }
190
+ if (queryCache.has(part)) return queryCache.get(part);
191
+ const distance = boundedDamerauLevenshtein(query, part, maxDistance);
192
+ queryCache.set(part, distance);
193
+ return distance;
194
+ }
195
+
196
+ function boundedDamerauLevenshtein(left: string, right: string, maxDistance: number): number | undefined {
197
+ if (Math.abs(left.length - right.length) > maxDistance) return undefined;
198
+
199
+ const unreachable = maxDistance + 1;
200
+ let previousPrevious = Array.from({ length: left.length + 1 }, () => unreachable);
201
+ let previous = Array.from({ length: left.length + 1 }, (_, index) => (index <= maxDistance ? index : unreachable));
202
+
203
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
204
+ const current = Array.from({ length: left.length + 1 }, () => unreachable);
205
+ if (rightIndex <= maxDistance) current[0] = rightIndex;
206
+ const start = Math.max(1, rightIndex - maxDistance);
207
+ const end = Math.min(left.length, rightIndex + maxDistance);
208
+ for (let leftIndex = start; leftIndex <= end; leftIndex += 1) {
209
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
210
+ current[leftIndex] = Math.min(
211
+ (previous[leftIndex] ?? unreachable) + 1,
212
+ (current[leftIndex - 1] ?? unreachable) + 1,
213
+ (previous[leftIndex - 1] ?? unreachable) + substitutionCost,
214
+ );
215
+ if (
216
+ leftIndex > 1 &&
217
+ rightIndex > 1 &&
218
+ left[leftIndex - 1] === right[rightIndex - 2] &&
219
+ left[leftIndex - 2] === right[rightIndex - 1]
220
+ ) {
221
+ current[leftIndex] = Math.min(
222
+ current[leftIndex] ?? unreachable,
223
+ (previousPrevious[leftIndex - 2] ?? unreachable) + 1,
224
+ );
225
+ }
226
+ }
227
+ previousPrevious = previous;
228
+ previous = current;
229
+ }
230
+
231
+ const distance = previous[left.length] ?? unreachable;
232
+ return distance <= maxDistance ? distance : undefined;
233
+ }
234
+
235
+ function allowedTypoDistance(queryLength: number): number {
236
+ if (queryLength < 5) return 0;
237
+ return queryLength < 10 ? 1 : 2;
238
+ }
239
+
240
+ function splitPathParts(value: string): string[] {
241
+ return value.split(PATH_PART_SEPARATOR).filter(Boolean);
242
+ }
243
+
244
+ function lengthPenalty(query: string, text: string): number {
245
+ return Math.max(0, text.length - query.length) / 100;
246
+ }
@@ -0,0 +1,125 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import type { ContextService } from "./context-service.js";
4
+ import { displayPath } from "./scope/display.js";
5
+ import type { ScopeService } from "./scope/edit-scope.js";
6
+
7
+ type Screen = "main" | "status" | "help";
8
+ type Action = "browse" | "review" | "scope" | "settings";
9
+
10
+ export async function showFilesMenu(
11
+ ctx: ExtensionCommandContext,
12
+ context: ContextService,
13
+ scope: ScopeService,
14
+ ): Promise<void> {
15
+ if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("/files requires Pi TUI mode");
16
+ const signal = AbortSignal.any([context.signal(ctx), scope.signal(ctx), ...(ctx.signal ? [ctx.signal] : [])]);
17
+ const current = () => !signal.aborted;
18
+ const state = () => ({ context: context.getState(ctx), scope: scope.getView(ctx) });
19
+ let next: Action | undefined;
20
+ const menu = defineMenu<ReturnType<typeof state>, Screen, Action, ExtensionCommandContext>({
21
+ start: "main",
22
+ screens: {
23
+ main: ({ state }) => ({
24
+ kind: "actions",
25
+ title: "Files",
26
+ lines: [
27
+ `Next prompt context: ${state.context.quotes.length} snippets`,
28
+ state.scope.state === "inactive"
29
+ ? "Edit scope: unrestricted"
30
+ : state.scope.state === "blocked"
31
+ ? "Edit scope: unavailable · edits blocked"
32
+ : `Edit scope: ${state.scope.targets.length} allowed files`,
33
+ ],
34
+ items: [
35
+ {
36
+ id: "browse",
37
+ label: "Browse files",
38
+ description: "Preview files and attach context; does not grant edit permission",
39
+ action: "browse",
40
+ },
41
+ {
42
+ id: "review",
43
+ label: `Review queued context (${state.context.quotes.length})`,
44
+ description: "Inspect exact snapshots and remove snippets",
45
+ action: "review",
46
+ disabled: !state.context.quotes.length,
47
+ },
48
+ {
49
+ id: "scope",
50
+ label: "Manage edit scope",
51
+ description: "Select allowed targets, reserve a new file, or end the restriction",
52
+ action: "scope",
53
+ },
54
+ {
55
+ id: "settings",
56
+ label: "Settings",
57
+ description: "Browser shortcut and hidden-file discovery preference",
58
+ action: "settings",
59
+ },
60
+ { id: "status", label: "Status", description: "Review context and every allowed target", to: "status" },
61
+ { id: "help", label: "Help", description: "Read state lifetimes and the protection boundary", to: "help" },
62
+ ],
63
+ hint: "close",
64
+ }),
65
+ status: ({ state }) => ({
66
+ kind: "detail",
67
+ title: "Files Status",
68
+ lines: [
69
+ `Next prompt context: ${state.context.quotes.length}/${state.context.maximumQuotes} snippets · ${state.context.totalBytes}/${state.context.maximumBytes} bytes`,
70
+ `Browser shortcut: ${state.context.shortcut ?? "disabled"}`,
71
+ `Edit scope: ${state.scope.state}`,
72
+ ...(state.scope.error ? [state.scope.error] : []),
73
+ ...state.scope.targets.map(
74
+ (target) =>
75
+ `${displayPath(target.path)}${target.canonical !== target.path ? ` → ${displayPath(target.canonical)}` : ""}`,
76
+ ),
77
+ ...(state.scope.state === "inactive" ? [] : ["/files off explicitly ends the editing restriction."]),
78
+ "Reserved new-file targets are created only by a normal write call.",
79
+ ],
80
+ hint: "back",
81
+ }),
82
+ help: () => ({
83
+ kind: "detail",
84
+ title: "Files Help",
85
+ lines: [
86
+ "Context snapshots attach once to the next ordinary prompt; selecting context never grants edit permission.",
87
+ "Edit scope lasts until explicitly ended and is restored from the active session branch; selecting a target never loads its content.",
88
+ "An active restriction allows only stock readers and target-checked native write/edit. Shell, codemode, delegation, and custom tools are blocked.",
89
+ "This is an in-process guard, not an OS sandbox. The user-controlled external editor is outside native-tool enforcement.",
90
+ "/file-context [browse|remove] and /edit-scope or /scope [off] keep their existing workflows.",
91
+ "Do not load predecessor extensions with Files. Remove or disable their user and project resources, then reload.",
92
+ "Escape goes back. Ctrl+C closes transient UI without ending an active restriction.",
93
+ ],
94
+ hint: "back",
95
+ }),
96
+ },
97
+ actions: {
98
+ browse: () => {
99
+ next = "browse";
100
+ return { kind: "close" };
101
+ },
102
+ review: () => {
103
+ next = "review";
104
+ return { kind: "close" };
105
+ },
106
+ scope: () => {
107
+ next = "scope";
108
+ return { kind: "close" };
109
+ },
110
+ settings: () => {
111
+ next = "settings";
112
+ return { kind: "close" };
113
+ },
114
+ },
115
+ });
116
+ const result = await runMenu(ctx, menu, { signal, isCurrent: current, getState: state });
117
+ if (!current() || result.kind !== "closed") return;
118
+ if (next === "browse") await context.browse(ctx);
119
+ else if (next === "review") await context.open(ctx, "remove");
120
+ else if (next === "scope") await scope.open(ctx, signal);
121
+ else if (next === "settings") {
122
+ const { showFilesSettings } = await import("./files-settings.js");
123
+ if (current()) await showFilesSettings(ctx, context, scope);
124
+ }
125
+ }