/ extensions / files-touched.ts
files-touched.ts
  1  /**
  2   * Files Touched
  3   *
  4  	* /files-touched command lists all files the model has read/written/edited/moved/deleted in the active
  5  	* session branch by native Pi tools and/or the tools of repopprompt-cli and repoprompt-mcp, coalesced by
  6  	* normalized path and sorted newest first. Selecting a file opens it in VS Code.
  7   */
  8  
  9  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 10  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
 11  import { Container, Key, matchesKey, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
 12  
 13  import { collectFilesTouched, type FilesTouchedEntry } from "./_shared/files-touched-core.ts";
 14  
 15  export default function (pi: ExtensionAPI) {
 16  	pi.registerCommand("files-touched", {
 17  		description: "Show files read/written/edited/moved/deleted in this session",
 18  		handler: async (_args, ctx) => {
 19  			if (!ctx.hasUI) {
 20  				ctx.ui.notify("No UI available", "error");
 21  				return;
 22  			}
 23  
 24  			const files = collectFilesTouched(ctx.sessionManager.getBranch(), ctx.cwd);
 25  			if (files.length === 0) {
 26  				ctx.ui.notify("No files read/written/edited/moved/deleted in this session", "info");
 27  				return;
 28  			}
 29  
 30  			const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
 31  			const quoteCmdArg = (value: string) => `"${value.replace(/"/g, '""')}"`;
 32  
 33  			const openWithCode = async (path: string) => {
 34  				if (process.platform === "win32") {
 35  					if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(path)) {
 36  						ctx.ui.notify(
 37  							`Refusing to open ${path}: path contains Windows cmd metacharacters (& | < > ^ % or newline).`,
 38  							"error",
 39  						);
 40  						return null;
 41  					}
 42  					const commandLine = `code -g ${quoteCmdArg(path)}`;
 43  					return pi.exec("cmd", ["/d", "/s", "/c", commandLine], { cwd: ctx.cwd });
 44  				}
 45  				return pi.exec("code", ["-g", path], { cwd: ctx.cwd });
 46  			};
 47  
 48  			const openSelected = async (file: FilesTouchedEntry): Promise<void> => {
 49  				try {
 50  					const openResult = await openWithCode(file.path);
 51  					if (!openResult) return;
 52  					if (openResult.code !== 0) {
 53  						const openStderr = openResult.stderr.trim();
 54  						ctx.ui.notify(
 55  							`Failed to open ${file.path} (exit ${openResult.code})${openStderr ? `: ${openStderr}` : ""}`,
 56  							"error",
 57  						);
 58  					}
 59  				} catch (error) {
 60  					const message = error instanceof Error ? error.message : String(error);
 61  					ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error");
 62  				}
 63  			};
 64  
 65  			await ctx.ui.custom<void>((tui, theme, _kb, done) => {
 66  				const container = new Container();
 67  				container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
 68  				container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
 69  
 70  				const items: SelectItem[] = files.map((file) => {
 71  					const ops: string[] = [];
 72  					if (file.operations.has("read")) ops.push(theme.fg("muted", "R"));
 73  					if (file.operations.has("write")) ops.push(theme.fg("success", "W"));
 74  					if (file.operations.has("edit")) ops.push(theme.fg("warning", "E"));
 75  					if (file.operations.has("move")) ops.push(theme.fg("accent", "M"));
 76  					if (file.operations.has("delete")) ops.push(theme.fg("error", "D"));
 77  
 78  					return {
 79  						value: file,
 80  						label: `${ops.join("")} ${file.displayPath}`,
 81  					};
 82  				});
 83  
 84  				const visibleRows = Math.min(files.length, 15);
 85  				let currentIndex = 0;
 86  
 87  				const selectList = new SelectList(items, visibleRows, {
 88  					selectedPrefix: (t) => theme.fg("accent", t),
 89  					selectedText: (t) => t,
 90  					description: (t) => theme.fg("muted", t),
 91  					scrollInfo: (t) => theme.fg("dim", t),
 92  					noMatch: (t) => theme.fg("warning", t),
 93  				});
 94  				selectList.onSelect = (item) => {
 95  					void openSelected(item.value as FilesTouchedEntry);
 96  				};
 97  				selectList.onCancel = () => done();
 98  				selectList.onSelectionChange = (item) => {
 99  					currentIndex = items.indexOf(item);
100  				};
101  				container.addChild(selectList);
102  
103  				container.addChild(
104  					new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
105  				);
106  				container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
107  
108  				return {
109  					render: (w) => container.render(w),
110  					invalidate: () => container.invalidate(),
111  					handleInput: (data) => {
112  						if (matchesKey(data, Key.left)) {
113  							currentIndex = Math.max(0, currentIndex - visibleRows);
114  							selectList.setSelectedIndex(currentIndex);
115  						} else if (matchesKey(data, Key.right)) {
116  							currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
117  							selectList.setSelectedIndex(currentIndex);
118  						} else {
119  							selectList.handleInput(data);
120  						}
121  						tui.requestRender();
122  					},
123  				};
124  			});
125  		},
126  	});
127  }