/** * Watch Extension * * Watches for file changes in the current directory and scans for AI comments. * Collects AI comments until an AI! or AI? trigger is found, then sends all comments to the agent. * * Usage: * pi --watch * /watch:start|stop|status|list|add|remove|ignore */ import path from "node:path"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@mariozechner/pi-coding-agent"; import chokidar from "chokidar"; import { createAIMessage, createAIQuestionMessage, DEFAULT_IGNORED_PATTERNS } from "./core.js"; import type { ParsedComment } from "./types.js"; import { CommentWatcher } from "./watcher.js"; export default function (pi: ExtensionAPI) { pi.registerFlag("watch", { description: "Watch current directory for file changes with AI comments", type: "boolean", default: false, }); let commentWatcher: CommentWatcher | null = null; let watchCwd: string | null = null; let watchCtx: Pick | null = null; function notify( ctx: Pick, message: string, type: "info" | "warning" | "error" = "info" ): void { if (ctx.hasUI) { ctx.ui.notify(message, type); } } function startWatching( ctx: Pick, requestedPath?: string ): void { const cwd = ctx.cwd; const watchPath = requestedPath ? path.resolve(cwd, requestedPath) : cwd; watchCwd = cwd; watchCtx = { hasUI: ctx.hasUI, ui: ctx.ui }; commentWatcher = new CommentWatcher( (paths, options) => chokidar.watch(paths, options), { onAIComment: (_comment: ParsedComment, allPending: ParsedComment[]) => { const uniqueFiles = new Set(allPending.map((c) => c.filePath)); notify( ctx, `${allPending.length} AI comment${allPending.length > 1 ? "s" : ""} collected from ${uniqueFiles.size} file${uniqueFiles.size > 1 ? "s" : ""}.` ); }, onAITrigger: (comments: ParsedComment[]) => { if (comments.length === 0) return; const message = createAIMessage(comments); try { pi.sendUserMessage(message, { deliverAs: "followUp" }); const uniqueFiles = new Set(comments.map((c) => c.filePath)); notify( ctx, `AI! comment found (sending ${comments.length} comment${comments.length > 1 ? "s" : ""} from ${uniqueFiles.size} file${uniqueFiles.size > 1 ? "s" : ""})` ); } catch (error) { notify(ctx, `Error sending message: ${error}`, "error"); } }, onAIQuestion: (comments: ParsedComment[]) => { if (comments.length === 0) return; const message = createAIQuestionMessage(comments); try { pi.sendUserMessage(message, { deliverAs: "followUp" }); const uniqueFiles = new Set(comments.map((c) => c.filePath)); notify( ctx, `AI? question found (sending ${comments.length} comment${comments.length > 1 ? "s" : ""} from ${uniqueFiles.size} file${uniqueFiles.size > 1 ? "s" : ""})` ); } catch (error) { notify(ctx, `Error sending question: ${error}`, "error"); } }, onReady: () => { notify(ctx, `Watching ${watchPath} for AI comments...`); }, onError: (error: Error) => { notify(ctx, `Watcher error: ${error.message}`, "error"); }, }, { cwd, ignoredPatterns: DEFAULT_IGNORED_PATTERNS, ignoreInitial: true, stabilityThreshold: 500, pollInterval: 50, } ); commentWatcher.watch(watchPath); } function formatStatus(): string { if (!commentWatcher) { return "Watch is stopped."; } const status = commentWatcher.getStatus(); return [ "Watch status:", `- Active: ${status.isWatching ? "yes" : "no"}`, `- Paused: ${status.isPaused ? "yes" : "no"}`, `- Paths: ${status.watchPaths.length ? status.watchPaths.join(", ") : "none"}`, `- Pending comments: ${status.pendingCount}`, `- Pending files: ${status.pendingFiles.length ? status.pendingFiles.join(", ") : "none"}`, `- Ignored patterns: ${status.ignoredPatterns.join(", ")}`, ].join("\n"); } function formatHelp(): string { return [ "Watch commands:", "/watch:start [dir] - start watching cwd or a specific path", "/watch:stop - stop watching and clear pending comments", "/watch:status - show state", "/watch:list - show pending AI comments", "/watch:add - add a watch path", "/watch:remove - remove a watch path", "/watch:ignore - add runtime ignore regex", ].join("\n"); } pi.on("agent_start", async () => { commentWatcher?.pause(); }); pi.on("agent_end", async () => { commentWatcher?.resume(); if (watchCtx && watchCwd) { notify(watchCtx, `Watching ${watchCwd} for AI comments...`); } }); pi.on("session_start", async (_event, ctx) => { if (pi.getFlag("watch")) { startWatching(ctx); } }); pi.registerCommand("watch", { description: "Show AI comment watching commands", handler: async (_args: string, ctx: ExtensionCommandContext) => { notify(ctx, formatHelp()); }, }); pi.registerCommand("watch:start", { description: "Start watching cwd or a specific path for AI comments", handler: async (args: string, ctx: ExtensionCommandContext) => { if (commentWatcher?.getStatus().isWatching) { notify(ctx, "Watch is already running. Use /watch:status to inspect it."); return; } startWatching(ctx, args.trim() || undefined); }, }); pi.registerCommand("watch:stop", { description: "Stop watching and clear pending AI comments", handler: async (_args: string, ctx: ExtensionCommandContext) => { if (!commentWatcher) { notify(ctx, "Watch is already stopped."); return; } commentWatcher.close(); commentWatcher = null; watchCwd = null; watchCtx = null; notify(ctx, "Watch stopped."); }, }); pi.registerCommand("watch:status", { description: "Show watch state, paths, pending comments, and ignores", handler: async (_args: string, ctx: ExtensionCommandContext) => { notify(ctx, formatStatus()); }, }); pi.registerCommand("watch:list", { description: "List pending AI comments grouped by file", handler: async (_args: string, ctx: ExtensionCommandContext) => { const comments = commentWatcher?.getPendingComments() ?? []; if (comments.length === 0) { notify(ctx, "No pending AI comments."); return; } notify( ctx, comments .map( (comment) => `${comment.filePath}:${comment.lineNumber}\n${comment.rawLines.join("\n")}` ) .join("\n\n") ); }, }); pi.registerCommand("watch:add", { description: "Add another directory to the active watch set", handler: async (args: string, ctx: ExtensionCommandContext) => { const requestedPath = args.trim(); if (!requestedPath) { notify(ctx, "Usage: /watch:add ", "error"); return; } if (!commentWatcher) { startWatching(ctx, requestedPath); return; } const resolvedPath = path.resolve(ctx.cwd, requestedPath); commentWatcher.addWatchPath(resolvedPath); notify(ctx, `Added ${resolvedPath} to watch paths.`); }, }); pi.registerCommand("watch:remove", { description: "Remove a directory from the active watch set", handler: async (args: string, ctx: ExtensionCommandContext) => { const requestedPath = args.trim(); if (!requestedPath) { notify(ctx, "Usage: /watch:remove ", "error"); return; } if (!commentWatcher) { notify(ctx, "Watch is stopped."); return; } const resolvedPath = path.resolve(ctx.cwd, requestedPath); commentWatcher.removeWatchPath(resolvedPath); notify(ctx, `Removed ${resolvedPath} from watch paths.`); }, }); pi.registerCommand("watch:ignore", { description: "Add a runtime regex ignore pattern", handler: async (args: string, ctx: ExtensionCommandContext) => { const pattern = args.trim(); if (!pattern) { notify(ctx, "Usage: /watch:ignore ", "error"); return; } if (!commentWatcher) { notify(ctx, "Start watch before adding runtime ignore patterns.", "error"); return; } try { commentWatcher.addIgnorePattern(new RegExp(pattern)); notify(ctx, `Ignoring paths matching /${pattern}/.`); } catch (error) { notify(ctx, `Invalid regex: ${error}`, "error"); } }, }); pi.on("session_shutdown", async () => { if (commentWatcher) { commentWatcher.close(); commentWatcher = null; } watchCwd = null; watchCtx = null; }); }