pi-agent-toolkit 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 (53) hide show
  1. package/dist/dotfiles/AGENTS.md +197 -0
  2. package/dist/dotfiles/APPEND_SYSTEM.md +78 -0
  3. package/dist/dotfiles/agent-modes.json +12 -0
  4. package/dist/dotfiles/agent-skills/exa-search/.env.example +4 -0
  5. package/dist/dotfiles/agent-skills/exa-search/SKILL.md +234 -0
  6. package/dist/dotfiles/agent-skills/exa-search/scripts/exa-api.cjs +197 -0
  7. package/dist/dotfiles/auth.json.template +5 -0
  8. package/dist/dotfiles/damage-control-rules.yaml +318 -0
  9. package/dist/dotfiles/extensions/btw.ts +1031 -0
  10. package/dist/dotfiles/extensions/commit-approval.ts +590 -0
  11. package/dist/dotfiles/extensions/context.ts +578 -0
  12. package/dist/dotfiles/extensions/control.ts +1748 -0
  13. package/dist/dotfiles/extensions/damage-control/index.ts +543 -0
  14. package/dist/dotfiles/extensions/damage-control/node_modules/.package-lock.json +22 -0
  15. package/dist/dotfiles/extensions/damage-control/package-lock.json +28 -0
  16. package/dist/dotfiles/extensions/damage-control/package.json +7 -0
  17. package/dist/dotfiles/extensions/dirty-repo-guard.ts +56 -0
  18. package/dist/dotfiles/extensions/exa-enforce.ts +51 -0
  19. package/dist/dotfiles/extensions/exa-search-tool.ts +384 -0
  20. package/dist/dotfiles/extensions/execute-command/index.ts +82 -0
  21. package/dist/dotfiles/extensions/files.ts +1112 -0
  22. package/dist/dotfiles/extensions/loop.ts +446 -0
  23. package/dist/dotfiles/extensions/pr-approval.ts +730 -0
  24. package/dist/dotfiles/extensions/qna-interactive.ts +532 -0
  25. package/dist/dotfiles/extensions/question-mode.ts +242 -0
  26. package/dist/dotfiles/extensions/require-session-name-on-exit.ts +141 -0
  27. package/dist/dotfiles/extensions/review.ts +2091 -0
  28. package/dist/dotfiles/extensions/session-breakdown.ts +1629 -0
  29. package/dist/dotfiles/extensions/term-notify.ts +150 -0
  30. package/dist/dotfiles/extensions/tilldone.ts +527 -0
  31. package/dist/dotfiles/extensions/todos.ts +2082 -0
  32. package/dist/dotfiles/extensions/tools.ts +146 -0
  33. package/dist/dotfiles/extensions/uv.ts +123 -0
  34. package/dist/dotfiles/global-skills/brainstorm/SKILL.md +10 -0
  35. package/dist/dotfiles/global-skills/cli-detector/SKILL.md +192 -0
  36. package/dist/dotfiles/global-skills/gh-issue-creator/SKILL.md +173 -0
  37. package/dist/dotfiles/global-skills/google-chat-cards-v2/SKILL.md +237 -0
  38. package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md +466 -0
  39. package/dist/dotfiles/global-skills/technical-docs/SKILL.md +204 -0
  40. package/dist/dotfiles/global-skills/technical-docs/references/diagrams.md +168 -0
  41. package/dist/dotfiles/global-skills/technical-docs/references/examples.md +449 -0
  42. package/dist/dotfiles/global-skills/technical-docs/scripts/validate_docs.py +352 -0
  43. package/dist/dotfiles/global-skills/whats-new/SKILL.md +159 -0
  44. package/dist/dotfiles/intercepted-commands/pip +7 -0
  45. package/dist/dotfiles/intercepted-commands/pip3 +7 -0
  46. package/dist/dotfiles/intercepted-commands/poetry +10 -0
  47. package/dist/dotfiles/intercepted-commands/python +104 -0
  48. package/dist/dotfiles/intercepted-commands/python3 +104 -0
  49. package/dist/dotfiles/mcp.json.template +32 -0
  50. package/dist/dotfiles/models.json +27 -0
  51. package/dist/dotfiles/settings.json +25 -0
  52. package/dist/index.js +1344 -0
  53. package/package.json +34 -0
@@ -0,0 +1,1112 @@
1
+ /**
2
+ * Files Extension
3
+ *
4
+ * /files command lists files in the current git tree (plus session-referenced files)
5
+ * and offers quick actions like reveal, open, edit, or diff.
6
+ * /diff is kept as an alias to the same picker.
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import {
11
+ existsSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
23
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
24
+ import {
25
+ Container,
26
+ fuzzyFilter,
27
+ Input,
28
+ matchesKey,
29
+ type SelectItem,
30
+ SelectList,
31
+ Spacer,
32
+ Text,
33
+ type TUI,
34
+ } from "@mariozechner/pi-tui";
35
+
36
+ type ContentBlock = {
37
+ type?: string;
38
+ text?: string;
39
+ arguments?: Record<string, unknown>;
40
+ };
41
+
42
+ type FileReference = {
43
+ path: string;
44
+ display: string;
45
+ exists: boolean;
46
+ isDirectory: boolean;
47
+ };
48
+
49
+ type FileEntry = {
50
+ canonicalPath: string;
51
+ resolvedPath: string;
52
+ displayPath: string;
53
+ exists: boolean;
54
+ isDirectory: boolean;
55
+ status?: string;
56
+ inRepo: boolean;
57
+ isTracked: boolean;
58
+ isReferenced: boolean;
59
+ hasSessionChange: boolean;
60
+ lastTimestamp: number;
61
+ };
62
+
63
+ type GitStatusEntry = {
64
+ status: string;
65
+ exists: boolean;
66
+ isDirectory: boolean;
67
+ };
68
+
69
+ type FileToolName = "write" | "edit";
70
+
71
+ type SessionFileChange = {
72
+ operations: Set<FileToolName>;
73
+ lastTimestamp: number;
74
+ };
75
+
76
+ const FILE_TAG_REGEX = /<file\s+name=["']([^"']+)["']>/g;
77
+ const FILE_URL_REGEX = /file:\/\/[^\s"'<>]+/g;
78
+ const PATH_REGEX = /(?:^|[\s"'`([{<])((?:~|\/)[^\s"'`<>)}\]]+)/g;
79
+
80
+ const MAX_EDIT_BYTES = 40 * 1024 * 1024;
81
+
82
+ const extractFileReferencesFromText = (text: string): string[] => {
83
+ const refs: string[] = [];
84
+
85
+ for (const match of text.matchAll(FILE_TAG_REGEX)) {
86
+ refs.push(match[1]);
87
+ }
88
+
89
+ for (const match of text.matchAll(FILE_URL_REGEX)) {
90
+ refs.push(match[0]);
91
+ }
92
+
93
+ for (const match of text.matchAll(PATH_REGEX)) {
94
+ refs.push(match[1]);
95
+ }
96
+
97
+ return refs;
98
+ };
99
+
100
+ const extractPathsFromToolArgs = (args: unknown): string[] => {
101
+ if (!args || typeof args !== "object") {
102
+ return [];
103
+ }
104
+
105
+ const refs: string[] = [];
106
+ const record = args as Record<string, unknown>;
107
+ const directKeys = ["path", "file", "filePath", "filepath", "fileName", "filename"] as const;
108
+ const listKeys = ["paths", "files", "filePaths"] as const;
109
+
110
+ for (const key of directKeys) {
111
+ const value = record[key];
112
+ if (typeof value === "string") {
113
+ refs.push(value);
114
+ }
115
+ }
116
+
117
+ for (const key of listKeys) {
118
+ const value = record[key];
119
+ if (Array.isArray(value)) {
120
+ for (const item of value) {
121
+ if (typeof item === "string") {
122
+ refs.push(item);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return refs;
129
+ };
130
+
131
+ const extractFileReferencesFromContent = (content: unknown): string[] => {
132
+ if (typeof content === "string") {
133
+ return extractFileReferencesFromText(content);
134
+ }
135
+
136
+ if (!Array.isArray(content)) {
137
+ return [];
138
+ }
139
+
140
+ const refs: string[] = [];
141
+ for (const part of content) {
142
+ if (!part || typeof part !== "object") {
143
+ continue;
144
+ }
145
+
146
+ const block = part as ContentBlock;
147
+
148
+ if (block.type === "text" && typeof block.text === "string") {
149
+ refs.push(...extractFileReferencesFromText(block.text));
150
+ }
151
+
152
+ if (block.type === "toolCall") {
153
+ refs.push(...extractPathsFromToolArgs(block.arguments));
154
+ }
155
+ }
156
+
157
+ return refs;
158
+ };
159
+
160
+ const extractFileReferencesFromEntry = (entry: SessionEntry): string[] => {
161
+ if (entry.type === "message") {
162
+ return extractFileReferencesFromContent(entry.message.content);
163
+ }
164
+
165
+ if (entry.type === "custom_message") {
166
+ return extractFileReferencesFromContent(entry.content);
167
+ }
168
+
169
+ return [];
170
+ };
171
+
172
+ const sanitizeReference = (raw: string): string => {
173
+ let value = raw.trim();
174
+ value = value.replace(/^["'`(<\[]+/, "");
175
+ value = value.replace(/[>"'`,;).\]]+$/, "");
176
+ value = value.replace(/[.,;:]+$/, "");
177
+ return value;
178
+ };
179
+
180
+ const isCommentLikeReference = (value: string): boolean => value.startsWith("//");
181
+
182
+ const stripLineSuffix = (value: string): string => {
183
+ let result = value.replace(/#L\d+(C\d+)?$/i, "");
184
+ const lastSeparator = Math.max(result.lastIndexOf("/"), result.lastIndexOf("\\"));
185
+ const segmentStart = lastSeparator >= 0 ? lastSeparator + 1 : 0;
186
+ const segment = result.slice(segmentStart);
187
+ const colonIndex = segment.indexOf(":");
188
+ if (colonIndex >= 0 && /\d/.test(segment[colonIndex + 1] ?? "")) {
189
+ result = result.slice(0, segmentStart + colonIndex);
190
+ return result;
191
+ }
192
+
193
+ const lastColon = result.lastIndexOf(":");
194
+ if (lastColon > lastSeparator) {
195
+ const suffix = result.slice(lastColon + 1);
196
+ if (/^\d+(?::\d+)?$/.test(suffix)) {
197
+ result = result.slice(0, lastColon);
198
+ }
199
+ }
200
+ return result;
201
+ };
202
+
203
+ const normalizeReferencePath = (raw: string, cwd: string): string | null => {
204
+ let candidate = sanitizeReference(raw);
205
+ if (!candidate || isCommentLikeReference(candidate)) {
206
+ return null;
207
+ }
208
+
209
+ if (candidate.startsWith("file://")) {
210
+ try {
211
+ candidate = fileURLToPath(candidate);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ candidate = stripLineSuffix(candidate);
218
+ if (!candidate || isCommentLikeReference(candidate)) {
219
+ return null;
220
+ }
221
+
222
+ if (candidate.startsWith("~")) {
223
+ candidate = path.join(os.homedir(), candidate.slice(1));
224
+ }
225
+
226
+ if (!path.isAbsolute(candidate)) {
227
+ candidate = path.resolve(cwd, candidate);
228
+ }
229
+
230
+ candidate = path.normalize(candidate);
231
+ const root = path.parse(candidate).root;
232
+ if (candidate.length > root.length) {
233
+ candidate = candidate.replace(/[\\/]+$/, "");
234
+ }
235
+
236
+ return candidate;
237
+ };
238
+
239
+ const formatDisplayPath = (absolutePath: string, cwd: string): string => {
240
+ const normalizedCwd = path.resolve(cwd);
241
+ if (absolutePath.startsWith(normalizedCwd + path.sep)) {
242
+ return path.relative(normalizedCwd, absolutePath);
243
+ }
244
+
245
+ return absolutePath;
246
+ };
247
+
248
+ const collectRecentFileReferences = (entries: SessionEntry[], cwd: string, limit: number): FileReference[] => {
249
+ const results: FileReference[] = [];
250
+ const seen = new Set<string>();
251
+
252
+ for (let i = entries.length - 1; i >= 0 && results.length < limit; i -= 1) {
253
+ const refs = extractFileReferencesFromEntry(entries[i]);
254
+ for (let j = refs.length - 1; j >= 0 && results.length < limit; j -= 1) {
255
+ const normalized = normalizeReferencePath(refs[j], cwd);
256
+ if (!normalized || seen.has(normalized)) {
257
+ continue;
258
+ }
259
+
260
+ seen.add(normalized);
261
+
262
+ let exists = false;
263
+ let isDirectory = false;
264
+ if (existsSync(normalized)) {
265
+ exists = true;
266
+ const stats = statSync(normalized);
267
+ isDirectory = stats.isDirectory();
268
+ }
269
+
270
+ results.push({
271
+ path: normalized,
272
+ display: formatDisplayPath(normalized, cwd),
273
+ exists,
274
+ isDirectory,
275
+ });
276
+ }
277
+ }
278
+
279
+ return results;
280
+ };
281
+
282
+ const findLatestFileReference = (entries: SessionEntry[], cwd: string): FileReference | null => {
283
+ const refs = collectRecentFileReferences(entries, cwd, 100);
284
+ return refs.find((ref) => ref.exists) ?? null;
285
+ };
286
+
287
+ const toCanonicalPath = (inputPath: string): { canonicalPath: string; isDirectory: boolean } | null => {
288
+ if (!existsSync(inputPath)) {
289
+ return null;
290
+ }
291
+
292
+ try {
293
+ const canonicalPath = realpathSync(inputPath);
294
+ const stats = statSync(canonicalPath);
295
+ return { canonicalPath, isDirectory: stats.isDirectory() };
296
+ } catch {
297
+ return null;
298
+ }
299
+ };
300
+
301
+ const toCanonicalPathMaybeMissing = (
302
+ inputPath: string,
303
+ ): { canonicalPath: string; isDirectory: boolean; exists: boolean } | null => {
304
+ const resolvedPath = path.resolve(inputPath);
305
+ if (!existsSync(resolvedPath)) {
306
+ return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: false };
307
+ }
308
+
309
+ try {
310
+ const canonicalPath = realpathSync(resolvedPath);
311
+ const stats = statSync(canonicalPath);
312
+ return { canonicalPath, isDirectory: stats.isDirectory(), exists: true };
313
+ } catch {
314
+ return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: true };
315
+ }
316
+ };
317
+
318
+ const collectSessionFileChanges = (entries: SessionEntry[], cwd: string): Map<string, SessionFileChange> => {
319
+ const toolCalls = new Map<string, { path: string; name: FileToolName }>();
320
+
321
+ for (const entry of entries) {
322
+ if (entry.type !== "message") continue;
323
+ const msg = entry.message;
324
+
325
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
326
+ for (const block of msg.content) {
327
+ if (block.type === "toolCall") {
328
+ const name = block.name as FileToolName;
329
+ if (name === "write" || name === "edit") {
330
+ const filePath = block.arguments?.path;
331
+ if (filePath && typeof filePath === "string") {
332
+ toolCalls.set(block.id, { path: filePath, name });
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ const fileMap = new Map<string, SessionFileChange>();
341
+
342
+ for (const entry of entries) {
343
+ if (entry.type !== "message") continue;
344
+ const msg = entry.message;
345
+
346
+ if (msg.role === "toolResult") {
347
+ const toolCall = toolCalls.get(msg.toolCallId);
348
+ if (!toolCall) continue;
349
+
350
+ const resolvedPath = path.isAbsolute(toolCall.path)
351
+ ? toolCall.path
352
+ : path.resolve(cwd, toolCall.path);
353
+ const canonical = toCanonicalPath(resolvedPath);
354
+ if (!canonical) {
355
+ continue;
356
+ }
357
+
358
+ const existing = fileMap.get(canonical.canonicalPath);
359
+ if (existing) {
360
+ existing.operations.add(toolCall.name);
361
+ if (msg.timestamp > existing.lastTimestamp) {
362
+ existing.lastTimestamp = msg.timestamp;
363
+ }
364
+ } else {
365
+ fileMap.set(canonical.canonicalPath, {
366
+ operations: new Set([toolCall.name]),
367
+ lastTimestamp: msg.timestamp,
368
+ });
369
+ }
370
+ }
371
+ }
372
+
373
+ return fileMap;
374
+ };
375
+
376
+ const splitNullSeparated = (value: string): string[] => value.split("\0").filter(Boolean);
377
+
378
+ const getGitRoot = async (pi: ExtensionAPI, cwd: string): Promise<string | null> => {
379
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
380
+ if (result.code !== 0) {
381
+ return null;
382
+ }
383
+
384
+ const root = result.stdout.trim();
385
+ return root ? root : null;
386
+ };
387
+
388
+ const getGitStatusMap = async (pi: ExtensionAPI, cwd: string): Promise<Map<string, GitStatusEntry>> => {
389
+ const statusMap = new Map<string, GitStatusEntry>();
390
+ const statusResult = await pi.exec("git", ["status", "--porcelain=1", "-z"], { cwd });
391
+ if (statusResult.code !== 0 || !statusResult.stdout) {
392
+ return statusMap;
393
+ }
394
+
395
+ const entries = splitNullSeparated(statusResult.stdout);
396
+ for (let i = 0; i < entries.length; i += 1) {
397
+ const entry = entries[i];
398
+ if (!entry || entry.length < 4) continue;
399
+ const status = entry.slice(0, 2);
400
+ const statusLabel = status.replace(/\s/g, "") || status.trim();
401
+ let filePath = entry.slice(3);
402
+ if ((status.startsWith("R") || status.startsWith("C")) && entries[i + 1]) {
403
+ filePath = entries[i + 1];
404
+ i += 1;
405
+ }
406
+ if (!filePath) continue;
407
+
408
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
409
+ const canonical = toCanonicalPathMaybeMissing(resolved);
410
+ if (!canonical) continue;
411
+ statusMap.set(canonical.canonicalPath, {
412
+ status: statusLabel,
413
+ exists: canonical.exists,
414
+ isDirectory: canonical.isDirectory,
415
+ });
416
+ }
417
+
418
+ return statusMap;
419
+ };
420
+
421
+ const getGitFiles = async (
422
+ pi: ExtensionAPI,
423
+ gitRoot: string,
424
+ ): Promise<{ tracked: Set<string>; files: Array<{ canonicalPath: string; isDirectory: boolean }> }> => {
425
+ const tracked = new Set<string>();
426
+ const files: Array<{ canonicalPath: string; isDirectory: boolean }> = [];
427
+
428
+ const trackedResult = await pi.exec("git", ["ls-files", "-z"], { cwd: gitRoot });
429
+ if (trackedResult.code === 0 && trackedResult.stdout) {
430
+ for (const relativePath of splitNullSeparated(trackedResult.stdout)) {
431
+ const resolvedPath = path.resolve(gitRoot, relativePath);
432
+ const canonical = toCanonicalPath(resolvedPath);
433
+ if (!canonical) continue;
434
+ tracked.add(canonical.canonicalPath);
435
+ files.push(canonical);
436
+ }
437
+ }
438
+
439
+ const untrackedResult = await pi.exec("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: gitRoot });
440
+ if (untrackedResult.code === 0 && untrackedResult.stdout) {
441
+ for (const relativePath of splitNullSeparated(untrackedResult.stdout)) {
442
+ const resolvedPath = path.resolve(gitRoot, relativePath);
443
+ const canonical = toCanonicalPath(resolvedPath);
444
+ if (!canonical) continue;
445
+ files.push(canonical);
446
+ }
447
+ }
448
+
449
+ return { tracked, files };
450
+ };
451
+
452
+ const buildFileEntries = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<{ files: FileEntry[]; gitRoot: string | null }> => {
453
+ const entries = ctx.sessionManager.getBranch();
454
+ const sessionChanges = collectSessionFileChanges(entries, ctx.cwd);
455
+ const gitRoot = await getGitRoot(pi, ctx.cwd);
456
+ const statusMap = gitRoot ? await getGitStatusMap(pi, gitRoot) : new Map<string, GitStatusEntry>();
457
+
458
+ let trackedSet = new Set<string>();
459
+ let gitFiles: Array<{ canonicalPath: string; isDirectory: boolean }> = [];
460
+ if (gitRoot) {
461
+ const gitListing = await getGitFiles(pi, gitRoot);
462
+ trackedSet = gitListing.tracked;
463
+ gitFiles = gitListing.files;
464
+ }
465
+
466
+ const fileMap = new Map<string, FileEntry>();
467
+
468
+ const upsertFile = (data: Partial<FileEntry> & { canonicalPath: string; isDirectory: boolean }) => {
469
+ const existing = fileMap.get(data.canonicalPath);
470
+ const displayPath = data.displayPath ?? formatDisplayPath(data.canonicalPath, ctx.cwd);
471
+
472
+ if (existing) {
473
+ fileMap.set(data.canonicalPath, {
474
+ ...existing,
475
+ ...data,
476
+ displayPath,
477
+ exists: data.exists ?? existing.exists,
478
+ isDirectory: data.isDirectory ?? existing.isDirectory,
479
+ isReferenced: existing.isReferenced || data.isReferenced === true,
480
+ inRepo: existing.inRepo || data.inRepo === true,
481
+ isTracked: existing.isTracked || data.isTracked === true,
482
+ hasSessionChange: existing.hasSessionChange || data.hasSessionChange === true,
483
+ lastTimestamp: Math.max(existing.lastTimestamp, data.lastTimestamp ?? 0),
484
+ });
485
+ return;
486
+ }
487
+
488
+ fileMap.set(data.canonicalPath, {
489
+ canonicalPath: data.canonicalPath,
490
+ resolvedPath: data.resolvedPath ?? data.canonicalPath,
491
+ displayPath,
492
+ exists: data.exists ?? true,
493
+ isDirectory: data.isDirectory,
494
+ status: data.status,
495
+ inRepo: data.inRepo ?? false,
496
+ isTracked: data.isTracked ?? false,
497
+ isReferenced: data.isReferenced ?? false,
498
+ hasSessionChange: data.hasSessionChange ?? false,
499
+ lastTimestamp: data.lastTimestamp ?? 0,
500
+ });
501
+ };
502
+
503
+ for (const file of gitFiles) {
504
+ upsertFile({
505
+ canonicalPath: file.canonicalPath,
506
+ resolvedPath: file.canonicalPath,
507
+ isDirectory: file.isDirectory,
508
+ exists: true,
509
+ status: statusMap.get(file.canonicalPath)?.status,
510
+ inRepo: true,
511
+ isTracked: trackedSet.has(file.canonicalPath),
512
+ });
513
+ }
514
+
515
+ for (const [canonicalPath, statusEntry] of statusMap.entries()) {
516
+ if (fileMap.has(canonicalPath)) {
517
+ continue;
518
+ }
519
+
520
+ const inRepo =
521
+ gitRoot !== null &&
522
+ !path.relative(gitRoot, canonicalPath).startsWith("..") &&
523
+ !path.isAbsolute(path.relative(gitRoot, canonicalPath));
524
+
525
+ upsertFile({
526
+ canonicalPath,
527
+ resolvedPath: canonicalPath,
528
+ isDirectory: statusEntry.isDirectory,
529
+ exists: statusEntry.exists,
530
+ status: statusEntry.status,
531
+ inRepo,
532
+ isTracked: trackedSet.has(canonicalPath) || statusEntry.status !== "??",
533
+ });
534
+ }
535
+
536
+ const references = collectRecentFileReferences(entries, ctx.cwd, 200).filter((ref) => ref.exists);
537
+ for (const ref of references) {
538
+ const canonical = toCanonicalPath(ref.path);
539
+ if (!canonical) continue;
540
+
541
+ const inRepo =
542
+ gitRoot !== null &&
543
+ !path.relative(gitRoot, canonical.canonicalPath).startsWith("..") &&
544
+ !path.isAbsolute(path.relative(gitRoot, canonical.canonicalPath));
545
+
546
+ upsertFile({
547
+ canonicalPath: canonical.canonicalPath,
548
+ resolvedPath: canonical.canonicalPath,
549
+ isDirectory: canonical.isDirectory,
550
+ exists: true,
551
+ status: statusMap.get(canonical.canonicalPath)?.status,
552
+ inRepo,
553
+ isTracked: trackedSet.has(canonical.canonicalPath),
554
+ isReferenced: true,
555
+ });
556
+ }
557
+
558
+ for (const [canonicalPath, change] of sessionChanges.entries()) {
559
+ const canonical = toCanonicalPath(canonicalPath);
560
+ if (!canonical) continue;
561
+
562
+ const inRepo =
563
+ gitRoot !== null &&
564
+ !path.relative(gitRoot, canonical.canonicalPath).startsWith("..") &&
565
+ !path.isAbsolute(path.relative(gitRoot, canonical.canonicalPath));
566
+
567
+ upsertFile({
568
+ canonicalPath: canonical.canonicalPath,
569
+ resolvedPath: canonical.canonicalPath,
570
+ isDirectory: canonical.isDirectory,
571
+ exists: true,
572
+ status: statusMap.get(canonical.canonicalPath)?.status,
573
+ inRepo,
574
+ isTracked: trackedSet.has(canonical.canonicalPath),
575
+ hasSessionChange: true,
576
+ lastTimestamp: change.lastTimestamp,
577
+ });
578
+ }
579
+
580
+ const files = Array.from(fileMap.values()).sort((a, b) => {
581
+ const aDirty = Boolean(a.status);
582
+ const bDirty = Boolean(b.status);
583
+ if (aDirty !== bDirty) {
584
+ return aDirty ? -1 : 1;
585
+ }
586
+ if (a.inRepo !== b.inRepo) {
587
+ return a.inRepo ? -1 : 1;
588
+ }
589
+ if (a.hasSessionChange !== b.hasSessionChange) {
590
+ return a.hasSessionChange ? -1 : 1;
591
+ }
592
+ if (a.lastTimestamp !== b.lastTimestamp) {
593
+ return b.lastTimestamp - a.lastTimestamp;
594
+ }
595
+ if (a.isReferenced !== b.isReferenced) {
596
+ return a.isReferenced ? -1 : 1;
597
+ }
598
+ return a.displayPath.localeCompare(b.displayPath);
599
+ });
600
+
601
+ return { files, gitRoot };
602
+ };
603
+
604
+ type EditCheckResult = {
605
+ allowed: boolean;
606
+ reason?: string;
607
+ content?: string;
608
+ };
609
+
610
+ const getEditableContent = (target: FileEntry): EditCheckResult => {
611
+ if (!existsSync(target.resolvedPath)) {
612
+ return { allowed: false, reason: "File not found" };
613
+ }
614
+
615
+ const stats = statSync(target.resolvedPath);
616
+ if (stats.isDirectory()) {
617
+ return { allowed: false, reason: "Directories cannot be edited" };
618
+ }
619
+
620
+ if (stats.size >= MAX_EDIT_BYTES) {
621
+ return { allowed: false, reason: "File is too large" };
622
+ }
623
+
624
+ const buffer = readFileSync(target.resolvedPath);
625
+ if (buffer.includes(0)) {
626
+ return { allowed: false, reason: "File contains null bytes" };
627
+ }
628
+
629
+ return { allowed: true, content: buffer.toString("utf8") };
630
+ };
631
+
632
+ const showActionSelector = async (
633
+ ctx: ExtensionContext,
634
+ options: { canQuickLook: boolean; canEdit: boolean; canDiff: boolean },
635
+ ): Promise<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null> => {
636
+ const actions: SelectItem[] = [
637
+ ...(options.canDiff ? [{ value: "diff", label: "Diff in VS Code" }] : []),
638
+ { value: "reveal", label: "Reveal in Finder" },
639
+ { value: "open", label: "Open" },
640
+ { value: "addToPrompt", label: "Add to prompt" },
641
+ ...(options.canQuickLook ? [{ value: "quicklook", label: "Open in Quick Look" }] : []),
642
+ ...(options.canEdit ? [{ value: "edit", label: "Edit" }] : []),
643
+ ];
644
+
645
+ return ctx.ui.custom<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null>((tui, theme, _kb, done) => {
646
+ const container = new Container();
647
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
648
+ container.addChild(new Text(theme.fg("accent", theme.bold("Choose action"))));
649
+
650
+ const selectList = new SelectList(actions, actions.length, {
651
+ selectedPrefix: (text) => theme.fg("accent", text),
652
+ selectedText: (text) => theme.fg("accent", text),
653
+ description: (text) => theme.fg("muted", text),
654
+ scrollInfo: (text) => theme.fg("dim", text),
655
+ noMatch: (text) => theme.fg("warning", text),
656
+ });
657
+
658
+ selectList.onSelect = (item) => done(item.value as "reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff");
659
+ selectList.onCancel = () => done(null);
660
+
661
+ container.addChild(selectList);
662
+ container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
663
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
664
+
665
+ return {
666
+ render(width: number) {
667
+ return container.render(width);
668
+ },
669
+ invalidate() {
670
+ container.invalidate();
671
+ },
672
+ handleInput(data: string) {
673
+ selectList.handleInput(data);
674
+ tui.requestRender();
675
+ },
676
+ };
677
+ });
678
+ };
679
+
680
+ const openPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
681
+ if (!existsSync(target.resolvedPath)) {
682
+ ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
683
+ return;
684
+ }
685
+
686
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
687
+ const result = await pi.exec(command, [target.resolvedPath]);
688
+ if (result.code !== 0) {
689
+ const errorMessage = result.stderr?.trim() || `Failed to open ${target.displayPath}`;
690
+ ctx.ui.notify(errorMessage, "error");
691
+ }
692
+ };
693
+
694
+ const openExternalEditor = (tui: TUI, editorCmd: string, content: string): string | null => {
695
+ const tmpFile = path.join(os.tmpdir(), `pi-files-edit-${Date.now()}.txt`);
696
+
697
+ try {
698
+ writeFileSync(tmpFile, content, "utf8");
699
+ tui.stop();
700
+
701
+ const [editor, ...editorArgs] = editorCmd.split(" ");
702
+ const result = spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit" });
703
+
704
+ if (result.status === 0) {
705
+ return readFileSync(tmpFile, "utf8").replace(/\n$/, "");
706
+ }
707
+
708
+ return null;
709
+ } finally {
710
+ try {
711
+ unlinkSync(tmpFile);
712
+ } catch {
713
+ }
714
+ tui.start();
715
+ tui.requestRender(true);
716
+ }
717
+ };
718
+
719
+ const editPath = async (ctx: ExtensionContext, target: FileEntry, content: string): Promise<void> => {
720
+ const editorCmd = process.env.VISUAL || process.env.EDITOR;
721
+ if (!editorCmd) {
722
+ ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
723
+ return;
724
+ }
725
+
726
+ const updated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
727
+ const status = new Text(theme.fg("dim", `Opening ${editorCmd}...`));
728
+
729
+ queueMicrotask(() => {
730
+ const result = openExternalEditor(tui, editorCmd, content);
731
+ done(result);
732
+ });
733
+
734
+ return status;
735
+ });
736
+
737
+ if (updated === null) {
738
+ ctx.ui.notify("Edit cancelled", "info");
739
+ return;
740
+ }
741
+
742
+ try {
743
+ writeFileSync(target.resolvedPath, updated, "utf8");
744
+ } catch {
745
+ ctx.ui.notify(`Failed to save ${target.displayPath}`, "error");
746
+ }
747
+ };
748
+
749
+ const revealPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
750
+ if (!existsSync(target.resolvedPath)) {
751
+ ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
752
+ return;
753
+ }
754
+
755
+ const isDirectory = target.isDirectory || statSync(target.resolvedPath).isDirectory();
756
+ let command = "open";
757
+ let args: string[] = [];
758
+
759
+ if (process.platform === "darwin") {
760
+ args = isDirectory ? [target.resolvedPath] : ["-R", target.resolvedPath];
761
+ } else {
762
+ command = "xdg-open";
763
+ args = [isDirectory ? target.resolvedPath : path.dirname(target.resolvedPath)];
764
+ }
765
+
766
+ const result = await pi.exec(command, args);
767
+ if (result.code !== 0) {
768
+ const errorMessage = result.stderr?.trim() || `Failed to reveal ${target.displayPath}`;
769
+ ctx.ui.notify(errorMessage, "error");
770
+ }
771
+ };
772
+
773
+ const quickLookPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
774
+ if (process.platform !== "darwin") {
775
+ ctx.ui.notify("Quick Look is only available on macOS", "warning");
776
+ return;
777
+ }
778
+
779
+ if (!existsSync(target.resolvedPath)) {
780
+ ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
781
+ return;
782
+ }
783
+
784
+ const isDirectory = target.isDirectory || statSync(target.resolvedPath).isDirectory();
785
+ if (isDirectory) {
786
+ ctx.ui.notify("Quick Look only works on files", "warning");
787
+ return;
788
+ }
789
+
790
+ const result = await pi.exec("qlmanage", ["-p", target.resolvedPath]);
791
+ if (result.code !== 0) {
792
+ const errorMessage = result.stderr?.trim() || `Failed to Quick Look ${target.displayPath}`;
793
+ ctx.ui.notify(errorMessage, "error");
794
+ }
795
+ };
796
+
797
+ const openDiff = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry, gitRoot: string | null): Promise<void> => {
798
+ if (!gitRoot) {
799
+ ctx.ui.notify("Git repository not found", "warning");
800
+ return;
801
+ }
802
+
803
+ const relativePath = path.relative(gitRoot, target.resolvedPath).split(path.sep).join("/");
804
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "pi-files-"));
805
+ const tmpFile = path.join(tmpDir, path.basename(target.displayPath));
806
+
807
+ const existsInHead = await pi.exec("git", ["cat-file", "-e", `HEAD:${relativePath}`], { cwd: gitRoot });
808
+ if (existsInHead.code === 0) {
809
+ const result = await pi.exec("git", ["show", `HEAD:${relativePath}`], { cwd: gitRoot });
810
+ if (result.code !== 0) {
811
+ const errorMessage = result.stderr?.trim() || `Failed to diff ${target.displayPath}`;
812
+ ctx.ui.notify(errorMessage, "error");
813
+ return;
814
+ }
815
+ writeFileSync(tmpFile, result.stdout ?? "", "utf8");
816
+ } else {
817
+ writeFileSync(tmpFile, "", "utf8");
818
+ }
819
+
820
+ let workingPath = target.resolvedPath;
821
+ if (!existsSync(target.resolvedPath)) {
822
+ workingPath = path.join(tmpDir, `pi-files-working-${path.basename(target.displayPath)}`);
823
+ writeFileSync(workingPath, "", "utf8");
824
+ }
825
+
826
+ const openResult = await pi.exec("code", ["--diff", tmpFile, workingPath], { cwd: gitRoot });
827
+ if (openResult.code !== 0) {
828
+ const errorMessage = openResult.stderr?.trim() || `Failed to open diff for ${target.displayPath}`;
829
+ ctx.ui.notify(errorMessage, "error");
830
+ }
831
+ };
832
+
833
+ const addFileToPrompt = (ctx: ExtensionContext, target: FileEntry): void => {
834
+ const mentionTarget = target.displayPath || target.resolvedPath;
835
+ const mention = `@${mentionTarget}`;
836
+ const current = ctx.ui.getEditorText();
837
+ const separator = current && !current.endsWith(" ") ? " " : "";
838
+ ctx.ui.setEditorText(`${current}${separator}${mention}`);
839
+ ctx.ui.notify(`Added ${mention} to prompt`, "info");
840
+ };
841
+
842
+ const showFileSelector = async (
843
+ ctx: ExtensionContext,
844
+ files: FileEntry[],
845
+ selectedPath?: string | null,
846
+ gitRoot?: string | null,
847
+ ): Promise<{ selected: FileEntry | null; quickAction: "diff" | null }> => {
848
+ const items: SelectItem[] = files.map((file) => {
849
+ const directoryLabel = file.isDirectory ? " [directory]" : "";
850
+ const statusSuffix = file.status ? ` [${file.status}]` : "";
851
+ return {
852
+ value: file.canonicalPath,
853
+ label: `${file.displayPath}${directoryLabel}${statusSuffix}`,
854
+ };
855
+ });
856
+
857
+ let quickAction: "diff" | null = null;
858
+ const selection = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
859
+ const container = new Container();
860
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
861
+ container.addChild(new Text(theme.fg("accent", theme.bold(" Select file")), 0, 0));
862
+
863
+ const searchInput = new Input();
864
+ container.addChild(searchInput);
865
+ container.addChild(new Spacer(1));
866
+
867
+ const listContainer = new Container();
868
+ container.addChild(listContainer);
869
+ container.addChild(
870
+ new Text(theme.fg("dim", "Type to filter • enter to select • ctrl+shift+d diff • esc to cancel"), 0, 0),
871
+ );
872
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
873
+
874
+ let filteredItems = items;
875
+ let selectList: SelectList | null = null;
876
+
877
+ const updateList = () => {
878
+ listContainer.clear();
879
+ if (filteredItems.length === 0) {
880
+ listContainer.addChild(new Text(theme.fg("warning", " No matching files"), 0, 0));
881
+ selectList = null;
882
+ return;
883
+ }
884
+
885
+ selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 12), {
886
+ selectedPrefix: (text) => theme.fg("accent", text),
887
+ selectedText: (text) => theme.fg("accent", text),
888
+ description: (text) => theme.fg("muted", text),
889
+ scrollInfo: (text) => theme.fg("dim", text),
890
+ noMatch: (text) => theme.fg("warning", text),
891
+ });
892
+
893
+ if (selectedPath) {
894
+ const index = filteredItems.findIndex((item) => item.value === selectedPath);
895
+ if (index >= 0) {
896
+ selectList.setSelectedIndex(index);
897
+ }
898
+ }
899
+
900
+ selectList.onSelect = (item) => done(item.value as string);
901
+ selectList.onCancel = () => done(null);
902
+
903
+ listContainer.addChild(selectList);
904
+ };
905
+
906
+ const applyFilter = () => {
907
+ const query = searchInput.getValue();
908
+ filteredItems = query
909
+ ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
910
+ : items;
911
+ updateList();
912
+ };
913
+
914
+ applyFilter();
915
+
916
+ return {
917
+ render(width: number) {
918
+ return container.render(width);
919
+ },
920
+ invalidate() {
921
+ container.invalidate();
922
+ },
923
+ handleInput(data: string) {
924
+ if (matchesKey(data, "ctrl+shift+d")) {
925
+ const selected = selectList?.getSelectedItem();
926
+ if (selected) {
927
+ const file = files.find((entry) => entry.canonicalPath === selected.value);
928
+ const canDiff = file?.isTracked && !file.isDirectory && Boolean(gitRoot);
929
+ if (!canDiff) {
930
+ ctx.ui.notify("Diff is only available for tracked files", "warning");
931
+ return;
932
+ }
933
+ quickAction = "diff";
934
+ done(selected.value as string);
935
+ return;
936
+ }
937
+ }
938
+
939
+ if (
940
+ keybindings.matches(data, "tui.select.up") ||
941
+ keybindings.matches(data, "tui.select.down") ||
942
+ keybindings.matches(data, "tui.select.confirm") ||
943
+ keybindings.matches(data, "tui.select.cancel")
944
+ ) {
945
+ if (selectList) {
946
+ selectList.handleInput(data);
947
+ } else if (keybindings.matches(data, "tui.select.cancel")) {
948
+ done(null);
949
+ }
950
+ tui.requestRender();
951
+ return;
952
+ }
953
+
954
+ searchInput.handleInput(data);
955
+ applyFilter();
956
+ tui.requestRender();
957
+ },
958
+ };
959
+ });
960
+
961
+ const selected = selection ? files.find((file) => file.canonicalPath === selection) ?? null : null;
962
+ return { selected, quickAction };
963
+ };
964
+
965
+ const runFileBrowser = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> => {
966
+ if (!ctx.hasUI) {
967
+ ctx.ui.notify("Files requires interactive mode", "error");
968
+ return;
969
+ }
970
+
971
+ const { files, gitRoot } = await buildFileEntries(pi, ctx);
972
+ if (files.length === 0) {
973
+ ctx.ui.notify("No files found", "info");
974
+ return;
975
+ }
976
+
977
+ let lastSelectedPath: string | null = null;
978
+ while (true) {
979
+ const { selected, quickAction } = await showFileSelector(ctx, files, lastSelectedPath, gitRoot);
980
+ if (!selected) {
981
+ ctx.ui.notify("Files cancelled", "info");
982
+ return;
983
+ }
984
+
985
+ lastSelectedPath = selected.canonicalPath;
986
+
987
+ const canQuickLook = process.platform === "darwin" && !selected.isDirectory;
988
+ const editCheck = getEditableContent(selected);
989
+ const canDiff = selected.isTracked && !selected.isDirectory && Boolean(gitRoot);
990
+
991
+ if (quickAction === "diff") {
992
+ await openDiff(pi, ctx, selected, gitRoot);
993
+ continue;
994
+ }
995
+
996
+ const action = await showActionSelector(ctx, {
997
+ canQuickLook,
998
+ canEdit: editCheck.allowed,
999
+ canDiff,
1000
+ });
1001
+ if (!action) {
1002
+ continue;
1003
+ }
1004
+
1005
+ switch (action) {
1006
+ case "quicklook":
1007
+ await quickLookPath(pi, ctx, selected);
1008
+ break;
1009
+ case "open":
1010
+ await openPath(pi, ctx, selected);
1011
+ break;
1012
+ case "edit":
1013
+ if (!editCheck.allowed || editCheck.content === undefined) {
1014
+ ctx.ui.notify(editCheck.reason ?? "File cannot be edited", "warning");
1015
+ break;
1016
+ }
1017
+ await editPath(ctx, selected, editCheck.content);
1018
+ break;
1019
+ case "addToPrompt":
1020
+ addFileToPrompt(ctx, selected);
1021
+ break;
1022
+ case "diff":
1023
+ await openDiff(pi, ctx, selected, gitRoot);
1024
+ break;
1025
+ default:
1026
+ await revealPath(pi, ctx, selected);
1027
+ break;
1028
+ }
1029
+ }
1030
+ };
1031
+
1032
+ export default function (pi: ExtensionAPI): void {
1033
+ pi.registerCommand("files", {
1034
+ description: "Browse files with git status and session references",
1035
+ handler: async (_args, ctx) => {
1036
+ await runFileBrowser(pi, ctx);
1037
+ },
1038
+ });
1039
+
1040
+ pi.registerShortcut("ctrl+shift+o", {
1041
+ description: "Browse files mentioned in the session",
1042
+ handler: async (ctx) => {
1043
+ await runFileBrowser(pi, ctx);
1044
+ },
1045
+ });
1046
+
1047
+ pi.registerShortcut("ctrl+shift+f", {
1048
+ description: "Reveal the latest file reference in Finder",
1049
+ handler: async (ctx) => {
1050
+ const entries = ctx.sessionManager.getBranch();
1051
+ const latest = findLatestFileReference(entries, ctx.cwd);
1052
+
1053
+ if (!latest) {
1054
+ ctx.ui.notify("No file reference found in the session", "warning");
1055
+ return;
1056
+ }
1057
+
1058
+ const canonical = toCanonicalPath(latest.path);
1059
+ if (!canonical) {
1060
+ ctx.ui.notify(`File not found: ${latest.display}`, "error");
1061
+ return;
1062
+ }
1063
+
1064
+ await revealPath(pi, ctx, {
1065
+ canonicalPath: canonical.canonicalPath,
1066
+ resolvedPath: canonical.canonicalPath,
1067
+ displayPath: latest.display,
1068
+ exists: true,
1069
+ isDirectory: canonical.isDirectory,
1070
+ status: undefined,
1071
+ inRepo: false,
1072
+ isTracked: false,
1073
+ isReferenced: true,
1074
+ hasSessionChange: false,
1075
+ lastTimestamp: 0,
1076
+ });
1077
+ },
1078
+ });
1079
+
1080
+ pi.registerShortcut("ctrl+shift+r", {
1081
+ description: "Quick Look the latest file reference",
1082
+ handler: async (ctx) => {
1083
+ const entries = ctx.sessionManager.getBranch();
1084
+ const latest = findLatestFileReference(entries, ctx.cwd);
1085
+
1086
+ if (!latest) {
1087
+ ctx.ui.notify("No file reference found in the session", "warning");
1088
+ return;
1089
+ }
1090
+
1091
+ const canonical = toCanonicalPath(latest.path);
1092
+ if (!canonical) {
1093
+ ctx.ui.notify(`File not found: ${latest.display}`, "error");
1094
+ return;
1095
+ }
1096
+
1097
+ await quickLookPath(pi, ctx, {
1098
+ canonicalPath: canonical.canonicalPath,
1099
+ resolvedPath: canonical.canonicalPath,
1100
+ displayPath: latest.display,
1101
+ exists: true,
1102
+ isDirectory: canonical.isDirectory,
1103
+ status: undefined,
1104
+ inRepo: false,
1105
+ isTracked: false,
1106
+ isReferenced: true,
1107
+ hasSessionChange: false,
1108
+ lastTimestamp: 0,
1109
+ });
1110
+ },
1111
+ });
1112
+ }