mitsupi 1.0.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 (77) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +95 -0
  3. package/TODO.md +11 -0
  4. package/commands/handoff.md +100 -0
  5. package/commands/make-release.md +75 -0
  6. package/commands/pickup.md +30 -0
  7. package/commands/update-changelog.md +78 -0
  8. package/package.json +22 -0
  9. package/pi-extensions/answer.ts +527 -0
  10. package/pi-extensions/codex-tuning.ts +632 -0
  11. package/pi-extensions/commit.ts +248 -0
  12. package/pi-extensions/cwd-history.ts +237 -0
  13. package/pi-extensions/issues.ts +548 -0
  14. package/pi-extensions/loop.ts +446 -0
  15. package/pi-extensions/qna.ts +167 -0
  16. package/pi-extensions/reveal.ts +689 -0
  17. package/pi-extensions/review.ts +807 -0
  18. package/pi-themes/armin.json +81 -0
  19. package/pi-themes/nightowl.json +82 -0
  20. package/skills/anachb/SKILL.md +183 -0
  21. package/skills/anachb/departures.sh +79 -0
  22. package/skills/anachb/disruptions.sh +53 -0
  23. package/skills/anachb/route.sh +87 -0
  24. package/skills/anachb/search.sh +43 -0
  25. package/skills/ghidra/SKILL.md +254 -0
  26. package/skills/ghidra/scripts/find-ghidra.sh +54 -0
  27. package/skills/ghidra/scripts/ghidra-analyze.sh +239 -0
  28. package/skills/ghidra/scripts/ghidra_scripts/ExportAll.java +278 -0
  29. package/skills/ghidra/scripts/ghidra_scripts/ExportCalls.java +148 -0
  30. package/skills/ghidra/scripts/ghidra_scripts/ExportDecompiled.java +84 -0
  31. package/skills/ghidra/scripts/ghidra_scripts/ExportFunctions.java +114 -0
  32. package/skills/ghidra/scripts/ghidra_scripts/ExportStrings.java +123 -0
  33. package/skills/ghidra/scripts/ghidra_scripts/ExportSymbols.java +135 -0
  34. package/skills/github/SKILL.md +47 -0
  35. package/skills/improve-skill/SKILL.md +155 -0
  36. package/skills/improve-skill/scripts/extract-session.js +349 -0
  37. package/skills/oebb-scotty/SKILL.md +429 -0
  38. package/skills/oebb-scotty/arrivals.sh +83 -0
  39. package/skills/oebb-scotty/departures.sh +83 -0
  40. package/skills/oebb-scotty/disruptions.sh +33 -0
  41. package/skills/oebb-scotty/search-station.sh +36 -0
  42. package/skills/oebb-scotty/trip.sh +119 -0
  43. package/skills/openscad/SKILL.md +232 -0
  44. package/skills/openscad/examples/parametric_box.scad +92 -0
  45. package/skills/openscad/examples/phone_stand.scad +95 -0
  46. package/skills/openscad/tools/common.sh +50 -0
  47. package/skills/openscad/tools/export-stl.sh +56 -0
  48. package/skills/openscad/tools/extract-params.sh +147 -0
  49. package/skills/openscad/tools/multi-preview.sh +68 -0
  50. package/skills/openscad/tools/preview.sh +74 -0
  51. package/skills/openscad/tools/render-with-params.sh +91 -0
  52. package/skills/openscad/tools/validate.sh +46 -0
  53. package/skills/pi-share/SKILL.md +105 -0
  54. package/skills/pi-share/fetch-session.mjs +322 -0
  55. package/skills/sentry/SKILL.md +239 -0
  56. package/skills/sentry/lib/auth.js +99 -0
  57. package/skills/sentry/scripts/fetch-event.js +329 -0
  58. package/skills/sentry/scripts/fetch-issue.js +356 -0
  59. package/skills/sentry/scripts/list-issues.js +239 -0
  60. package/skills/sentry/scripts/search-events.js +291 -0
  61. package/skills/sentry/scripts/search-logs.js +240 -0
  62. package/skills/tmux/SKILL.md +105 -0
  63. package/skills/tmux/scripts/find-sessions.sh +112 -0
  64. package/skills/tmux/scripts/wait-for-text.sh +83 -0
  65. package/skills/web-browser/SKILL.md +91 -0
  66. package/skills/web-browser/scripts/cdp.js +210 -0
  67. package/skills/web-browser/scripts/dismiss-cookies.js +373 -0
  68. package/skills/web-browser/scripts/eval.js +68 -0
  69. package/skills/web-browser/scripts/logs-tail.js +69 -0
  70. package/skills/web-browser/scripts/nav.js +65 -0
  71. package/skills/web-browser/scripts/net-summary.js +94 -0
  72. package/skills/web-browser/scripts/package-lock.json +33 -0
  73. package/skills/web-browser/scripts/package.json +6 -0
  74. package/skills/web-browser/scripts/pick.js +165 -0
  75. package/skills/web-browser/scripts/screenshot.js +52 -0
  76. package/skills/web-browser/scripts/start.js +80 -0
  77. package/skills/web-browser/scripts/watch.js +266 -0
@@ -0,0 +1,689 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
7
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
8
+ import {
9
+ Container,
10
+ type SelectItem,
11
+ SelectList,
12
+ Text,
13
+ type TUI,
14
+ Input,
15
+ Spacer,
16
+ fuzzyFilter,
17
+ getEditorKeybindings,
18
+ } from "@mariozechner/pi-tui";
19
+
20
+ type ContentBlock = {
21
+ type?: string;
22
+ text?: string;
23
+ arguments?: Record<string, unknown>;
24
+ };
25
+
26
+ type FileReference = {
27
+ path: string;
28
+ display: string;
29
+ exists: boolean;
30
+ isDirectory: boolean;
31
+ };
32
+
33
+ const FILE_TAG_REGEX = /<file\s+name=["']([^"']+)["']>/g;
34
+ const FILE_URL_REGEX = /file:\/\/[^\s"'<>]+/g;
35
+ const PATH_REGEX = /(?:^|[\s"'`([{<])((?:~|\/)[^\s"'`<>)}\]]+)/g;
36
+
37
+ const MAX_EDIT_BYTES = 40 * 1024 * 1024;
38
+
39
+ const extractFileReferencesFromText = (text: string): string[] => {
40
+ const refs: string[] = [];
41
+
42
+ for (const match of text.matchAll(FILE_TAG_REGEX)) {
43
+ refs.push(match[1]);
44
+ }
45
+
46
+ for (const match of text.matchAll(FILE_URL_REGEX)) {
47
+ refs.push(match[0]);
48
+ }
49
+
50
+ for (const match of text.matchAll(PATH_REGEX)) {
51
+ refs.push(match[1]);
52
+ }
53
+
54
+ return refs;
55
+ };
56
+
57
+ const extractPathsFromToolArgs = (args: unknown): string[] => {
58
+ if (!args || typeof args !== "object") {
59
+ return [];
60
+ }
61
+
62
+ const refs: string[] = [];
63
+ const record = args as Record<string, unknown>;
64
+ const directKeys = ["path", "file", "filePath", "filepath", "fileName", "filename"] as const;
65
+ const listKeys = ["paths", "files", "filePaths"] as const;
66
+
67
+ for (const key of directKeys) {
68
+ const value = record[key];
69
+ if (typeof value === "string") {
70
+ refs.push(value);
71
+ }
72
+ }
73
+
74
+ for (const key of listKeys) {
75
+ const value = record[key];
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) {
78
+ if (typeof item === "string") {
79
+ refs.push(item);
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return refs;
86
+ };
87
+
88
+ const extractFileReferencesFromContent = (content: unknown): string[] => {
89
+ if (typeof content === "string") {
90
+ return extractFileReferencesFromText(content);
91
+ }
92
+
93
+ if (!Array.isArray(content)) {
94
+ return [];
95
+ }
96
+
97
+ const refs: string[] = [];
98
+ for (const part of content) {
99
+ if (!part || typeof part !== "object") {
100
+ continue;
101
+ }
102
+
103
+ const block = part as ContentBlock;
104
+
105
+ if (block.type === "text" && typeof block.text === "string") {
106
+ refs.push(...extractFileReferencesFromText(block.text));
107
+ }
108
+
109
+ if (block.type === "toolCall") {
110
+ refs.push(...extractPathsFromToolArgs(block.arguments));
111
+ }
112
+ }
113
+
114
+ return refs;
115
+ };
116
+
117
+ const extractFileReferencesFromEntry = (entry: SessionEntry): string[] => {
118
+ if (entry.type === "message") {
119
+ return extractFileReferencesFromContent(entry.message.content);
120
+ }
121
+
122
+ if (entry.type === "custom_message") {
123
+ return extractFileReferencesFromContent(entry.content);
124
+ }
125
+
126
+ return [];
127
+ };
128
+
129
+ const sanitizeReference = (raw: string): string => {
130
+ let value = raw.trim();
131
+ value = value.replace(/^["'`(<\[]+/, "");
132
+ value = value.replace(/[>"'`,;).\]]+$/, "");
133
+ value = value.replace(/[.,;:]+$/, "");
134
+ return value;
135
+ };
136
+
137
+ const isCommentLikeReference = (value: string): boolean => value.startsWith("//");
138
+
139
+ const stripLineSuffix = (value: string): string => {
140
+ let result = value.replace(/#L\d+(C\d+)?$/i, "");
141
+ const lastSeparator = Math.max(result.lastIndexOf("/"), result.lastIndexOf("\\"));
142
+ const segmentStart = lastSeparator >= 0 ? lastSeparator + 1 : 0;
143
+ const segment = result.slice(segmentStart);
144
+ const colonIndex = segment.indexOf(":");
145
+ if (colonIndex >= 0 && /\d/.test(segment[colonIndex + 1] ?? "")) {
146
+ result = result.slice(0, segmentStart + colonIndex);
147
+ return result;
148
+ }
149
+
150
+ const lastColon = result.lastIndexOf(":");
151
+ if (lastColon > lastSeparator) {
152
+ const suffix = result.slice(lastColon + 1);
153
+ if (/^\d+(?::\d+)?$/.test(suffix)) {
154
+ result = result.slice(0, lastColon);
155
+ }
156
+ }
157
+ return result;
158
+ };
159
+
160
+ const normalizeReferencePath = (raw: string, cwd: string): string | null => {
161
+ let candidate = sanitizeReference(raw);
162
+ if (!candidate || isCommentLikeReference(candidate)) {
163
+ return null;
164
+ }
165
+
166
+ if (candidate.startsWith("file://")) {
167
+ try {
168
+ candidate = fileURLToPath(candidate);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ candidate = stripLineSuffix(candidate);
175
+ if (!candidate || isCommentLikeReference(candidate)) {
176
+ return null;
177
+ }
178
+
179
+ if (candidate.startsWith("~")) {
180
+ candidate = path.join(os.homedir(), candidate.slice(1));
181
+ }
182
+
183
+ if (!path.isAbsolute(candidate)) {
184
+ candidate = path.resolve(cwd, candidate);
185
+ }
186
+
187
+ candidate = path.normalize(candidate);
188
+ const root = path.parse(candidate).root;
189
+ if (candidate.length > root.length) {
190
+ candidate = candidate.replace(/[\\/]+$/, "");
191
+ }
192
+
193
+ return candidate;
194
+ };
195
+
196
+ const formatDisplayPath = (absolutePath: string, cwd: string): string => {
197
+ const normalizedCwd = path.resolve(cwd);
198
+ if (absolutePath.startsWith(normalizedCwd + path.sep)) {
199
+ return path.relative(normalizedCwd, absolutePath);
200
+ }
201
+ return absolutePath;
202
+ };
203
+
204
+ const collectRecentFileReferences = (entries: SessionEntry[], cwd: string, limit: number): FileReference[] => {
205
+ const results: FileReference[] = [];
206
+ const seen = new Set<string>();
207
+
208
+ for (let i = entries.length - 1; i >= 0 && results.length < limit; i -= 1) {
209
+ const refs = extractFileReferencesFromEntry(entries[i]);
210
+ for (let j = refs.length - 1; j >= 0 && results.length < limit; j -= 1) {
211
+ const normalized = normalizeReferencePath(refs[j], cwd);
212
+ if (!normalized || seen.has(normalized)) {
213
+ continue;
214
+ }
215
+
216
+ seen.add(normalized);
217
+
218
+ let exists = false;
219
+ let isDirectory = false;
220
+ if (existsSync(normalized)) {
221
+ exists = true;
222
+ const stats = statSync(normalized);
223
+ isDirectory = stats.isDirectory();
224
+ }
225
+
226
+ results.push({
227
+ path: normalized,
228
+ display: formatDisplayPath(normalized, cwd),
229
+ exists,
230
+ isDirectory,
231
+ });
232
+ }
233
+ }
234
+
235
+ return results;
236
+ };
237
+
238
+ const findLatestFileReference = (entries: SessionEntry[], cwd: string): FileReference | null => {
239
+ const refs = collectRecentFileReferences(entries, cwd, 100);
240
+ return refs.find((ref) => ref.exists) ?? null;
241
+ };
242
+
243
+ const showFileSelector = async (
244
+ ctx: ExtensionContext,
245
+ items: FileReference[],
246
+ selectedPath?: string | null,
247
+ ): Promise<FileReference | null> => {
248
+ const seenPaths = new Set<string>();
249
+ const uniqueItems = items.filter((item) => {
250
+ if (seenPaths.has(item.path)) {
251
+ return false;
252
+ }
253
+ seenPaths.add(item.path);
254
+ return true;
255
+ });
256
+ const orderedItems = uniqueItems.filter((item) => item.exists);
257
+
258
+ const selectItems: SelectItem[] = orderedItems.map((item) => {
259
+ const status = item.isDirectory ? " [directory]" : "";
260
+ return {
261
+ value: item.path,
262
+ label: `${item.display}${status}`,
263
+ description: "",
264
+ };
265
+ });
266
+
267
+ return ctx.ui.custom<FileReference | null>((tui, theme, _kb, done) => {
268
+ const container = new Container();
269
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
270
+ container.addChild(new Text(theme.fg("accent", theme.bold("Select a file"))));
271
+
272
+ const searchInput = new Input();
273
+ container.addChild(searchInput);
274
+ container.addChild(new Spacer(1));
275
+
276
+ const listContainer = new Container();
277
+ container.addChild(listContainer);
278
+ container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
279
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
280
+
281
+ let filteredItems = selectItems;
282
+ let selectList: SelectList | null = null;
283
+
284
+ const updateList = () => {
285
+ listContainer.clear();
286
+
287
+ if (filteredItems.length === 0) {
288
+ listContainer.addChild(new Text(theme.fg("warning", " No matching files"), 0, 0));
289
+ selectList = null;
290
+ return;
291
+ }
292
+
293
+ selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 12), {
294
+ selectedPrefix: (text) => theme.fg("accent", text),
295
+ selectedText: (text) => theme.fg("accent", text),
296
+ description: (text) => theme.fg("muted", text),
297
+ scrollInfo: (text) => theme.fg("dim", text),
298
+ noMatch: (text) => theme.fg("warning", text),
299
+ });
300
+
301
+ if (selectedPath) {
302
+ const index = filteredItems.findIndex((item) => item.value === selectedPath);
303
+ if (index >= 0) {
304
+ selectList.setSelectedIndex(index);
305
+ }
306
+ }
307
+
308
+ selectList.onSelect = (item) => {
309
+ const selected = orderedItems.find((entry) => entry.path === item.value);
310
+ done(selected ?? null);
311
+ };
312
+ selectList.onCancel = () => done(null);
313
+
314
+ listContainer.addChild(selectList);
315
+ };
316
+
317
+ const applyFilter = () => {
318
+ const query = searchInput.getValue();
319
+ filteredItems = query
320
+ ? fuzzyFilter(selectItems, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
321
+ : selectItems;
322
+ updateList();
323
+ };
324
+
325
+ applyFilter();
326
+
327
+ return {
328
+ render(width: number) {
329
+ return container.render(width);
330
+ },
331
+ invalidate() {
332
+ container.invalidate();
333
+ },
334
+ handleInput(data: string) {
335
+ const kb = getEditorKeybindings();
336
+ if (
337
+ kb.matches(data, "selectUp") ||
338
+ kb.matches(data, "selectDown") ||
339
+ kb.matches(data, "selectConfirm") ||
340
+ kb.matches(data, "selectCancel")
341
+ ) {
342
+ if (selectList) {
343
+ selectList.handleInput(data);
344
+ } else if (kb.matches(data, "selectCancel")) {
345
+ done(null);
346
+ }
347
+ tui.requestRender();
348
+ return;
349
+ }
350
+
351
+ searchInput.handleInput(data);
352
+ applyFilter();
353
+ tui.requestRender();
354
+ },
355
+ };
356
+ });
357
+ };
358
+
359
+ type EditCheckResult = {
360
+ allowed: boolean;
361
+ reason?: string;
362
+ content?: string;
363
+ };
364
+
365
+ const getEditableContent = (target: FileReference): EditCheckResult => {
366
+ if (!existsSync(target.path)) {
367
+ return { allowed: false, reason: "File not found" };
368
+ }
369
+
370
+ const stats = statSync(target.path);
371
+ if (stats.isDirectory()) {
372
+ return { allowed: false, reason: "Directories cannot be edited" };
373
+ }
374
+
375
+ if (stats.size >= MAX_EDIT_BYTES) {
376
+ return { allowed: false, reason: "File is too large" };
377
+ }
378
+
379
+ const buffer = readFileSync(target.path);
380
+ if (buffer.includes(0)) {
381
+ return { allowed: false, reason: "File contains null bytes" };
382
+ }
383
+
384
+ return { allowed: true, content: buffer.toString("utf8") };
385
+ };
386
+
387
+ const showActionSelector = async (
388
+ ctx: ExtensionContext,
389
+ options: { canQuickLook: boolean; canEdit: boolean },
390
+ ): Promise<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | null> => {
391
+ const actions: SelectItem[] = [
392
+ { value: "reveal", label: "Reveal in Finder" },
393
+ { value: "open", label: "Open" },
394
+ { value: "addToPrompt", label: "Add to prompt" },
395
+ ...(options.canQuickLook ? [{ value: "quicklook", label: "Open in Quick Look" }] : []),
396
+ ...(options.canEdit ? [{ value: "edit", label: "Edit" }] : []),
397
+ ];
398
+
399
+ return ctx.ui.custom<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | null>((tui, theme, _kb, done) => {
400
+ const container = new Container();
401
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
402
+ container.addChild(new Text(theme.fg("accent", theme.bold("Choose action"))));
403
+
404
+ const selectList = new SelectList(actions, actions.length, {
405
+ selectedPrefix: (text) => theme.fg("accent", text),
406
+ selectedText: (text) => theme.fg("accent", text),
407
+ description: (text) => theme.fg("muted", text),
408
+ scrollInfo: (text) => theme.fg("dim", text),
409
+ noMatch: (text) => theme.fg("warning", text),
410
+ });
411
+
412
+ selectList.onSelect = (item) =>
413
+ done(item.value as "reveal" | "quicklook" | "open" | "edit" | "addToPrompt");
414
+ selectList.onCancel = () => done(null);
415
+
416
+ container.addChild(selectList);
417
+ container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
418
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
419
+
420
+ return {
421
+ render(width: number) {
422
+ return container.render(width);
423
+ },
424
+ invalidate() {
425
+ container.invalidate();
426
+ },
427
+ handleInput(data: string) {
428
+ selectList.handleInput(data);
429
+ tui.requestRender();
430
+ },
431
+ };
432
+ });
433
+ };
434
+
435
+ const openPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileReference): Promise<void> => {
436
+ if (!existsSync(target.path)) {
437
+ if (ctx.hasUI) {
438
+ ctx.ui.notify(`File not found: ${target.path}`, "error");
439
+ }
440
+ return;
441
+ }
442
+
443
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
444
+ const result = await pi.exec(command, [target.path]);
445
+ if (result.code !== 0 && ctx.hasUI) {
446
+ const errorMessage = result.stderr?.trim() || `Failed to open ${target.path}`;
447
+ ctx.ui.notify(errorMessage, "error");
448
+ }
449
+ };
450
+
451
+ const openExternalEditor = (tui: TUI, editorCmd: string, content: string): string | null => {
452
+ const tmpFile = path.join(os.tmpdir(), `pi-reveal-edit-${Date.now()}.txt`);
453
+
454
+ try {
455
+ writeFileSync(tmpFile, content, "utf8");
456
+ tui.stop();
457
+
458
+ const [editor, ...editorArgs] = editorCmd.split(" ");
459
+ const result = spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit" });
460
+
461
+ if (result.status === 0) {
462
+ return readFileSync(tmpFile, "utf8").replace(/\n$/, "");
463
+ }
464
+
465
+ return null;
466
+ } finally {
467
+ try {
468
+ unlinkSync(tmpFile);
469
+ } catch {
470
+ }
471
+ tui.start();
472
+ tui.requestRender(true);
473
+ }
474
+ };
475
+
476
+ const editPath = async (
477
+ ctx: ExtensionContext,
478
+ target: FileReference,
479
+ content: string,
480
+ ): Promise<void> => {
481
+ const editorCmd = process.env.VISUAL || process.env.EDITOR;
482
+ if (!editorCmd) {
483
+ ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
484
+ return;
485
+ }
486
+
487
+ const updated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
488
+ const status = new Text(theme.fg("dim", `Opening ${editorCmd}...`));
489
+
490
+ queueMicrotask(() => {
491
+ const result = openExternalEditor(tui, editorCmd, content);
492
+ done(result);
493
+ });
494
+
495
+ return status;
496
+ });
497
+
498
+ if (updated === null) {
499
+ ctx.ui.notify("Edit cancelled", "info");
500
+ return;
501
+ }
502
+
503
+ try {
504
+ writeFileSync(target.path, updated, "utf8");
505
+ } catch {
506
+ ctx.ui.notify(`Failed to save ${target.path}`, "error");
507
+ return;
508
+ }
509
+ };
510
+
511
+
512
+ const revealPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileReference): Promise<void> => {
513
+ if (!existsSync(target.path)) {
514
+ if (ctx.hasUI) {
515
+ ctx.ui.notify(`File not found: ${target.path}`, "error");
516
+ }
517
+ return;
518
+ }
519
+
520
+ const isDirectory = target.isDirectory || statSync(target.path).isDirectory();
521
+ let command = "open";
522
+ let args: string[] = [];
523
+
524
+ if (process.platform === "darwin") {
525
+ args = isDirectory ? [target.path] : ["-R", target.path];
526
+ } else {
527
+ command = "xdg-open";
528
+ args = [isDirectory ? target.path : path.dirname(target.path)];
529
+ }
530
+
531
+ const result = await pi.exec(command, args);
532
+ if (result.code !== 0 && ctx.hasUI) {
533
+ const errorMessage = result.stderr?.trim() || `Failed to reveal ${target.path}`;
534
+ ctx.ui.notify(errorMessage, "error");
535
+ }
536
+ };
537
+
538
+ const quickLookPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileReference): Promise<void> => {
539
+ if (process.platform !== "darwin") {
540
+ if (ctx.hasUI) {
541
+ ctx.ui.notify("Quick Look is only available on macOS", "warning");
542
+ }
543
+ return;
544
+ }
545
+
546
+ if (!existsSync(target.path)) {
547
+ if (ctx.hasUI) {
548
+ ctx.ui.notify(`File not found: ${target.path}`, "error");
549
+ }
550
+ return;
551
+ }
552
+
553
+ const isDirectory = target.isDirectory || statSync(target.path).isDirectory();
554
+ if (isDirectory) {
555
+ if (ctx.hasUI) {
556
+ ctx.ui.notify("Quick Look only works on files", "warning");
557
+ }
558
+ return;
559
+ }
560
+
561
+ const result = await pi.exec("qlmanage", ["-p", target.path]);
562
+ if (result.code !== 0 && ctx.hasUI) {
563
+ const errorMessage = result.stderr?.trim() || `Failed to Quick Look ${target.path}`;
564
+ ctx.ui.notify(errorMessage, "error");
565
+ }
566
+ };
567
+
568
+ const addFileToPrompt = (ctx: ExtensionContext, target: FileReference): void => {
569
+ const mentionTarget = target.display || target.path;
570
+ const mention = `@${mentionTarget}`;
571
+ const current = ctx.ui.getEditorText();
572
+ const separator = current && !current.endsWith(" ") ? " " : "";
573
+ ctx.ui.setEditorText(`${current}${separator}${mention}`);
574
+ ctx.ui.notify(`Added ${mention} to prompt`, "info");
575
+ };
576
+
577
+ const runFileBrowser = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> => {
578
+ if (!ctx.hasUI) {
579
+ ctx.ui.notify("Reveal requires interactive mode", "error");
580
+ return;
581
+ }
582
+
583
+ const entries = ctx.sessionManager.getBranch();
584
+ const references = collectRecentFileReferences(entries, ctx.cwd, 100);
585
+
586
+ if (references.length === 0) {
587
+ ctx.ui.notify("No file reference found in the session", "warning");
588
+ return;
589
+ }
590
+
591
+ let lastSelectedPath: string | null = null;
592
+ while (true) {
593
+ const selection = await showFileSelector(ctx, references, lastSelectedPath);
594
+ if (!selection) {
595
+ ctx.ui.notify("Reveal cancelled", "info");
596
+ return;
597
+ }
598
+
599
+ lastSelectedPath = selection.path;
600
+
601
+ if (!selection.exists) {
602
+ ctx.ui.notify(`File not found: ${selection.path}`, "error");
603
+ return;
604
+ }
605
+
606
+ const editCheck = getEditableContent(selection);
607
+ const canQuickLook = process.platform === "darwin" && !selection.isDirectory;
608
+
609
+ const action = await showActionSelector(ctx, {
610
+ canQuickLook,
611
+ canEdit: editCheck.allowed,
612
+ });
613
+ if (!action) {
614
+ continue;
615
+ }
616
+
617
+ switch (action) {
618
+ case "quicklook":
619
+ await quickLookPath(pi, ctx, selection);
620
+ return;
621
+ case "open":
622
+ await openPath(pi, ctx, selection);
623
+ return;
624
+ case "edit":
625
+ if (!editCheck.allowed || editCheck.content === undefined) {
626
+ ctx.ui.notify(editCheck.reason ?? "File cannot be edited", "warning");
627
+ return;
628
+ }
629
+ await editPath(ctx, selection, editCheck.content);
630
+ return;
631
+ case "addToPrompt":
632
+ addFileToPrompt(ctx, selection);
633
+ return;
634
+ default:
635
+ await revealPath(pi, ctx, selection);
636
+ return;
637
+ }
638
+ }
639
+ };
640
+
641
+ export default function (pi: ExtensionAPI): void {
642
+ pi.registerCommand("files", {
643
+ description: "Reveal, open, or edit files mentioned in the conversation",
644
+ handler: async (_args, ctx) => {
645
+ await runFileBrowser(pi, ctx);
646
+ },
647
+ });
648
+
649
+ pi.registerShortcut("ctrl+f", {
650
+ description: "Browse files mentioned in the session",
651
+ handler: async (ctx) => {
652
+ await runFileBrowser(pi, ctx);
653
+ },
654
+ });
655
+
656
+ pi.registerShortcut("ctrl+r", {
657
+ description: "Reveal the latest file reference in Finder",
658
+ handler: async (ctx) => {
659
+ const entries = ctx.sessionManager.getBranch();
660
+ const latest = findLatestFileReference(entries, ctx.cwd);
661
+
662
+ if (!latest) {
663
+ if (ctx.hasUI) {
664
+ ctx.ui.notify("No file reference found in the session", "warning");
665
+ }
666
+ return;
667
+ }
668
+
669
+ await revealPath(pi, ctx, latest);
670
+ },
671
+ });
672
+
673
+ pi.registerShortcut("ctrl+shift+r", {
674
+ description: "Quick Look the latest file reference",
675
+ handler: async (ctx) => {
676
+ const entries = ctx.sessionManager.getBranch();
677
+ const latest = findLatestFileReference(entries, ctx.cwd);
678
+
679
+ if (!latest) {
680
+ if (ctx.hasUI) {
681
+ ctx.ui.notify("No file reference found in the session", "warning");
682
+ }
683
+ return;
684
+ }
685
+
686
+ await quickLookPath(pi, ctx, latest);
687
+ },
688
+ });
689
+ }