moonpi 0.4.2

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.
package/src/config.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ import type { MoonpiConfig, MoonpiMode } from "./types.js";
5
+
6
+ export const DEFAULT_PICKABLE_EXTENSIONS = [
7
+ // JavaScript / TypeScript
8
+ ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts",
9
+ // Python
10
+ ".py", ".pyw", ".pyi",
11
+ // Ruby
12
+ ".rb", ".erb",
13
+ // Go
14
+ ".go",
15
+ // Rust
16
+ ".rs",
17
+ // C / C++
18
+ ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx",
19
+ // Java / Kotlin / Scala / Clojure
20
+ ".java", ".kt", ".kts", ".scala", ".clj", ".cljs",
21
+ // C#
22
+ ".cs", ".csx",
23
+ // Swift / Objective-C
24
+ ".swift", ".m", ".mm",
25
+ // Other languages
26
+ ".zig", ".dart", ".lua", ".r", ".R", ".php", ".pl", ".pm", ".ex", ".exs", ".erl", ".hs", ".ml",
27
+ // Shell
28
+ ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
29
+ // Web
30
+ ".html", ".htm", ".css", ".scss", ".sass", ".less", ".vue", ".svelte", ".svg",
31
+ // Query / schema
32
+ ".sql", ".graphql", ".gql", ".prisma", ".proto",
33
+ // Data / config
34
+ ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".ini", ".cfg", ".conf", ".csv",
35
+ // Docs
36
+ ".md", ".mdx", ".txt", ".rst", ".adoc", ".tex",
37
+ // Common extensionless filenames
38
+ "Dockerfile", "Makefile", "Gemfile", "Rakefile", ".gitignore", ".gitattributes", ".editorconfig",
39
+ ];
40
+
41
+ export const DEFAULT_CONFIG: MoonpiConfig = {
42
+ defaultMode: "auto",
43
+ preserveExternalTools: true,
44
+ customEditor: true,
45
+ contextFiles: {
46
+ enabled: true,
47
+ fileNames: ["README.md", "SPECS.md", "SPRINT.md"],
48
+ maxTotalBytes: 120_000,
49
+ maxDepth: 4,
50
+ maxScannedEntries: 10_000,
51
+ maxDefaultFiles: 25,
52
+ pickableExtensions: DEFAULT_PICKABLE_EXTENSIONS,
53
+ ignoreDirs: [
54
+ // Version control
55
+ ".git", ".hg", ".svn",
56
+ // Pi config
57
+ ".pi",
58
+ // Node.js
59
+ "node_modules", ".next", ".turbo", ".nuxt", "bower_components",
60
+ // Python
61
+ ".venv", "venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache", "site-packages", ".eggs",
62
+ // Rust
63
+ "target",
64
+ // Go
65
+ "vendor",
66
+ // Java / Kotlin
67
+ ".gradle", "out",
68
+ // Ruby
69
+ "vendor", "bundle",
70
+ // Elixir / Erlang
71
+ "_build", "deps",
72
+ // Build output
73
+ "dist", "build", "coverage", ".cache", ".output",
74
+ // Docker / env
75
+ ".env",
76
+ // IDE / editor
77
+ ".idea", ".vscode", ".vs",
78
+ // OS
79
+ "__MACOSX",
80
+ // Infra
81
+ ".terraform", ".serverless",
82
+ // Misc
83
+ ".parcel-cache",
84
+ ],
85
+ },
86
+ guards: {
87
+ cwdOnly: true,
88
+ allowedPaths: [],
89
+ readBeforeWrite: true,
90
+ },
91
+ keybindings: {
92
+ cycleNext: "tab",
93
+ cyclePrevious: "",
94
+ },
95
+ };
96
+
97
+ function isRecord(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value);
99
+ }
100
+
101
+ function isMode(value: unknown): value is MoonpiMode {
102
+ return value === "plan" || value === "act" || value === "auto" || value === "fast";
103
+ }
104
+
105
+ /** User-selectable modes for config defaults (excludes sprint internal modes). */
106
+ function isSelectableMode(value: unknown): value is MoonpiMode {
107
+ return value === "plan" || value === "act" || value === "auto" || value === "fast";
108
+ }
109
+
110
+ function readJsonFile(filePath: string): Record<string, unknown> | undefined {
111
+ if (!existsSync(filePath)) return undefined;
112
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown;
113
+ return isRecord(parsed) ? parsed : undefined;
114
+ }
115
+
116
+ function readStringArray(value: unknown, fallback: string[]): string[] {
117
+ if (!Array.isArray(value)) return fallback;
118
+ const strings = value.filter((entry): entry is string => typeof entry === "string");
119
+ return strings.length > 0 ? strings : fallback;
120
+ }
121
+
122
+ function mergeConfig(base: MoonpiConfig, raw: Record<string, unknown> | undefined): MoonpiConfig {
123
+ if (!raw) return base;
124
+ const next: MoonpiConfig = {
125
+ defaultMode: base.defaultMode,
126
+ preserveExternalTools: base.preserveExternalTools,
127
+ customEditor: base.customEditor,
128
+ contextFiles: { ...base.contextFiles },
129
+ guards: { ...base.guards },
130
+ keybindings: { ...base.keybindings },
131
+ };
132
+
133
+ if (isSelectableMode(raw.defaultMode)) next.defaultMode = raw.defaultMode;
134
+ if (typeof raw.preserveExternalTools === "boolean") next.preserveExternalTools = raw.preserveExternalTools;
135
+ if (typeof raw.customEditor === "boolean") next.customEditor = raw.customEditor;
136
+
137
+ if (isRecord(raw.contextFiles)) {
138
+ const context = raw.contextFiles;
139
+ if (typeof context.enabled === "boolean") next.contextFiles.enabled = context.enabled;
140
+ next.contextFiles.fileNames = readStringArray(context.fileNames, next.contextFiles.fileNames);
141
+ next.contextFiles.pickableExtensions = readStringArray(context.pickableExtensions, next.contextFiles.pickableExtensions);
142
+ next.contextFiles.ignoreDirs = readStringArray(context.ignoreDirs, next.contextFiles.ignoreDirs);
143
+ if (typeof context.maxTotalBytes === "number" && Number.isFinite(context.maxTotalBytes)) {
144
+ next.contextFiles.maxTotalBytes = Math.max(0, Math.floor(context.maxTotalBytes));
145
+ }
146
+ if (typeof context.maxDepth === "number" && Number.isFinite(context.maxDepth)) {
147
+ next.contextFiles.maxDepth = Math.max(0, Math.floor(context.maxDepth));
148
+ }
149
+ if (typeof context.maxScannedEntries === "number" && Number.isFinite(context.maxScannedEntries)) {
150
+ next.contextFiles.maxScannedEntries = Math.max(0, Math.floor(context.maxScannedEntries));
151
+ }
152
+ if (typeof context.maxDefaultFiles === "number" && Number.isFinite(context.maxDefaultFiles)) {
153
+ next.contextFiles.maxDefaultFiles = Math.max(0, Math.floor(context.maxDefaultFiles));
154
+ }
155
+ }
156
+
157
+ if (isRecord(raw.guards)) {
158
+ if (typeof raw.guards.cwdOnly === "boolean") next.guards.cwdOnly = raw.guards.cwdOnly;
159
+ next.guards.allowedPaths = readStringArray(raw.guards.allowedPaths, next.guards.allowedPaths);
160
+ if (typeof raw.guards.readBeforeWrite === "boolean") {
161
+ next.guards.readBeforeWrite = raw.guards.readBeforeWrite;
162
+ }
163
+ }
164
+
165
+ if (isRecord(raw.keybindings)) {
166
+ if (typeof raw.keybindings.cycleNext === "string") next.keybindings.cycleNext = raw.keybindings.cycleNext;
167
+ if (typeof raw.keybindings.cyclePrevious === "string") {
168
+ next.keybindings.cyclePrevious = raw.keybindings.cyclePrevious;
169
+ }
170
+ }
171
+
172
+ return next;
173
+ }
174
+
175
+ export function loadMoonpiConfig(cwd: string): MoonpiConfig {
176
+ const globalConfig = readJsonFile(join(getAgentDir(), "moonpi.json"));
177
+ const projectConfig = readJsonFile(join(cwd, ".pi", "moonpi.json"));
178
+ return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig);
179
+ }
180
+
181
+ export function formatConfig(config: MoonpiConfig): string {
182
+ return JSON.stringify(config, null, 2);
183
+ }
@@ -0,0 +1,518 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
5
+ import type { MoonpiController } from "./modes.js";
6
+
7
+ interface LoadedContextFile {
8
+ path: string;
9
+ relativePath: string;
10
+ content: string;
11
+ }
12
+
13
+ interface PickerNode {
14
+ type: "dir" | "file";
15
+ name: string;
16
+ path: string;
17
+ relativePath: string;
18
+ children: PickerNode[];
19
+ expanded: boolean;
20
+ loaded: boolean;
21
+ parent?: PickerNode;
22
+ }
23
+
24
+ interface VisiblePickerNode {
25
+ node: PickerNode;
26
+ depth: number;
27
+ }
28
+
29
+ interface PickResult {
30
+ confirmed: boolean;
31
+ selectedPaths: string[];
32
+ }
33
+
34
+ interface ScanStats {
35
+ scannedEntries: number;
36
+ truncatedByEntryLimit: boolean;
37
+ truncatedByDepthLimit: boolean;
38
+ truncatedByDefaultFileLimit: boolean;
39
+ }
40
+
41
+ interface DefaultContextFileDiscovery {
42
+ paths: string[];
43
+ stats: ScanStats;
44
+ }
45
+
46
+ interface PickerTreeResult {
47
+ root: PickerNode;
48
+ stats: ScanStats;
49
+ }
50
+
51
+ function createScanStats(): ScanStats {
52
+ return {
53
+ scannedEntries: 0,
54
+ truncatedByEntryLimit: false,
55
+ truncatedByDepthLimit: false,
56
+ truncatedByDefaultFileLimit: false,
57
+ };
58
+ }
59
+
60
+ function isInsideRoot(root: string, filePath: string): boolean {
61
+ const resolvedRoot = resolve(root);
62
+ const resolvedFile = resolve(filePath);
63
+ return resolvedFile === resolvedRoot || resolvedFile.startsWith(`${resolvedRoot}${sep}`);
64
+ }
65
+
66
+ function sortEntries(left: PickerNode, right: PickerNode): number {
67
+ if (left.type !== right.type) return left.type === "dir" ? -1 : 1;
68
+ return left.name.localeCompare(right.name);
69
+ }
70
+
71
+ function safeReadDir(dir: string): Dirent<string>[] {
72
+ try {
73
+ return readdirSync(dir, { withFileTypes: true });
74
+ } catch {
75
+ // Permission errors and other inaccessible folders are intentionally skipped.
76
+ return [];
77
+ }
78
+ }
79
+
80
+ function shouldSkipDir(name: string, ignoredDirs: Set<string>): boolean {
81
+ return ignoredDirs.has(name);
82
+ }
83
+
84
+ function isPickableFile(name: string, pickable: Set<string>): boolean {
85
+ if (pickable.has(name)) return true;
86
+ const dotIndex = name.lastIndexOf(".");
87
+ if (dotIndex >= 0) return pickable.has(name.slice(dotIndex));
88
+ return false;
89
+ }
90
+
91
+ function canScanMore(stats: ScanStats, maxScannedEntries: number): boolean {
92
+ if (stats.scannedEntries < maxScannedEntries) return true;
93
+ stats.truncatedByEntryLimit = true;
94
+ return false;
95
+ }
96
+
97
+ function findDefaultContextFilePaths(cwd: string, controller: MoonpiController): DefaultContextFileDiscovery {
98
+ const config = controller.config.contextFiles;
99
+ const stats = createScanStats();
100
+ if (!config.enabled || !existsSync(cwd)) return { paths: [], stats };
101
+
102
+ if (config.maxDefaultFiles <= 0) {
103
+ stats.truncatedByDefaultFileLimit = true;
104
+ return { paths: [], stats };
105
+ }
106
+
107
+ const fileNames = new Set(config.fileNames);
108
+ const ignoredDirs = new Set(config.ignoreDirs);
109
+ const found: string[] = [];
110
+ const stack: Array<{ dir: string; depth: number }> = [{ dir: cwd, depth: 0 }];
111
+
112
+ while (stack.length > 0) {
113
+ const current = stack.pop();
114
+ if (!current) break;
115
+
116
+ const entries = safeReadDir(current.dir).sort((left, right) => left.name.localeCompare(right.name));
117
+ for (const entry of entries) {
118
+ if (!canScanMore(stats, config.maxScannedEntries)) break;
119
+ stats.scannedEntries += 1;
120
+ if (entry.isSymbolicLink()) continue;
121
+
122
+ const fullPath = join(current.dir, entry.name);
123
+ if (entry.isDirectory()) {
124
+ if (shouldSkipDir(entry.name, ignoredDirs)) continue;
125
+ if (current.depth >= config.maxDepth) {
126
+ stats.truncatedByDepthLimit = true;
127
+ continue;
128
+ }
129
+ stack.push({ dir: fullPath, depth: current.depth + 1 });
130
+ continue;
131
+ }
132
+
133
+ if (entry.isFile() && fileNames.has(entry.name)) {
134
+ found.push(relative(cwd, fullPath));
135
+ if (found.length >= config.maxDefaultFiles) {
136
+ stats.truncatedByDefaultFileLimit = true;
137
+ stack.length = 0;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ found.sort((left, right) => left.localeCompare(right));
145
+ return { paths: found, stats };
146
+ }
147
+
148
+ function getEffectiveSelectedContextFilePaths(cwd: string, controller: MoonpiController): string[] {
149
+ if (!controller.config.contextFiles.enabled || !existsSync(cwd)) return [];
150
+ const selected = controller.state.selectedContextFilePaths;
151
+ return selected === undefined ? findDefaultContextFilePaths(cwd, controller).paths : [...selected].sort((left, right) => left.localeCompare(right));
152
+ }
153
+
154
+ function loadContextFiles(cwd: string, controller: MoonpiController): LoadedContextFile[] {
155
+ const config = controller.config.contextFiles;
156
+ if (!config.enabled || !existsSync(cwd)) return [];
157
+
158
+ const selected = getEffectiveSelectedContextFilePaths(cwd, controller);
159
+ const loaded: LoadedContextFile[] = [];
160
+ let totalBytes = 0;
161
+
162
+ for (const relativePath of selected) {
163
+ const filePath = resolve(cwd, relativePath);
164
+ if (!isInsideRoot(cwd, filePath)) continue;
165
+ try {
166
+ const stat = statSync(filePath);
167
+ if (!stat.isFile()) continue;
168
+ if (totalBytes >= config.maxTotalBytes) break;
169
+ const remaining = config.maxTotalBytes - totalBytes;
170
+ const raw = readFileSync(filePath, "utf-8");
171
+ const content = raw.length > remaining ? raw.slice(0, remaining) : raw;
172
+ totalBytes += content.length;
173
+ loaded.push({ path: filePath, relativePath, content });
174
+ } catch {
175
+ // Deleted, unreadable, or binary-hostile paths are skipped instead of breaking the turn.
176
+ }
177
+ }
178
+
179
+ return loaded;
180
+ }
181
+
182
+ function loadPickerChildren(cwd: string, controller: MoonpiController, node: PickerNode, stats: ScanStats): void {
183
+ if (node.type !== "dir" || node.loaded) return;
184
+
185
+ const config = controller.config.contextFiles;
186
+ const ignoredDirs = new Set(config.ignoreDirs);
187
+ const pickableExtensions = new Set(config.pickableExtensions);
188
+ for (const entry of safeReadDir(node.path)) {
189
+ if (!canScanMore(stats, config.maxScannedEntries)) break;
190
+ stats.scannedEntries += 1;
191
+ if (entry.isSymbolicLink()) continue;
192
+
193
+ const filePath = join(node.path, entry.name);
194
+ if (entry.isDirectory()) {
195
+ if (shouldSkipDir(entry.name, ignoredDirs)) continue;
196
+ node.children.push({
197
+ type: "dir",
198
+ name: entry.name,
199
+ path: filePath,
200
+ relativePath: relative(cwd, filePath),
201
+ children: [],
202
+ expanded: false,
203
+ loaded: false,
204
+ parent: node,
205
+ });
206
+ } else if (entry.isFile()) {
207
+ if (!isPickableFile(entry.name, pickableExtensions)) continue;
208
+ node.children.push({
209
+ type: "file",
210
+ name: entry.name,
211
+ path: filePath,
212
+ relativePath: relative(cwd, filePath),
213
+ children: [],
214
+ expanded: false,
215
+ loaded: true,
216
+ parent: node,
217
+ });
218
+ }
219
+ }
220
+
221
+ node.children.sort(sortEntries);
222
+ node.loaded = true;
223
+ }
224
+
225
+ function buildPickerTree(cwd: string, controller: MoonpiController): PickerTreeResult {
226
+ const stats = createScanStats();
227
+ const root: PickerNode = {
228
+ type: "dir",
229
+ name: ".",
230
+ path: cwd,
231
+ relativePath: "",
232
+ children: [],
233
+ expanded: true,
234
+ loaded: false,
235
+ };
236
+ loadPickerChildren(cwd, controller, root, stats);
237
+ return { root, stats };
238
+ }
239
+
240
+ function formatScanLimitMessage(stats: ScanStats): string | undefined {
241
+ const limits: string[] = [];
242
+ if (stats.truncatedByEntryLimit) limits.push("entry limit");
243
+ if (stats.truncatedByDepthLimit) limits.push("depth limit");
244
+ if (stats.truncatedByDefaultFileLimit) limits.push("default-file limit");
245
+ if (limits.length === 0) return undefined;
246
+ return `scan truncated by ${limits.join(", ")} after ${stats.scannedEntries} entries`;
247
+ }
248
+
249
+ function collectVisibleNodes(root: PickerNode): VisiblePickerNode[] {
250
+ const visible: VisiblePickerNode[] = [];
251
+ function visit(node: PickerNode, depth: number): void {
252
+ visible.push({ node, depth });
253
+ if (node.type === "dir" && node.expanded) {
254
+ for (const child of node.children) visit(child, depth + 1);
255
+ }
256
+ }
257
+ visit(root, 0);
258
+ return visible;
259
+ }
260
+
261
+ function collectFilePaths(node: PickerNode): string[] {
262
+ if (node.type === "file") return [node.relativePath];
263
+ const paths: string[] = [];
264
+ for (const child of node.children) paths.push(...collectFilePaths(child));
265
+ return paths;
266
+ }
267
+
268
+ function selectedState(node: PickerNode, selected: Set<string>): "none" | "partial" | "all" {
269
+ const filePaths = collectFilePaths(node);
270
+ if (filePaths.length === 0) {
271
+ if (node.type === "dir" && !node.loaded) {
272
+ const prefix = node.relativePath ? `${node.relativePath}/` : "";
273
+ return [...selected].some((path) => path.startsWith(prefix)) ? "partial" : "none";
274
+ }
275
+ return "none";
276
+ }
277
+ const selectedCount = filePaths.filter((path) => selected.has(path)).length;
278
+ if (selectedCount === 0) return "none";
279
+ if (selectedCount === filePaths.length) return "all";
280
+ return "partial";
281
+ }
282
+
283
+ function escapeAttribute(value: string): string {
284
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
285
+ }
286
+
287
+ export function installContextFiles(pi: ExtensionAPI, controller: MoonpiController): void {
288
+ pi.registerCommand("pick", {
289
+ description: "Choose project files injected into the agent prompt",
290
+ handler: async (_args, ctx) => {
291
+ if (!ctx.hasUI) {
292
+ ctx.ui.notify("/pick requires the interactive UI.", "error");
293
+ return;
294
+ }
295
+ if (!controller.config.contextFiles.enabled) {
296
+ ctx.ui.notify("moonpi context file injection is disabled in /moonpi:settings.", "warning");
297
+ }
298
+
299
+ const tree = buildPickerTree(ctx.cwd, controller);
300
+ const root = tree.root;
301
+ const selected = new Set(getEffectiveSelectedContextFilePaths(ctx.cwd, controller));
302
+ let scanLimitMessage = formatScanLimitMessage(tree.stats);
303
+ if (scanLimitMessage) ctx.ui.notify(`/pick ${scanLimitMessage}. Narrow contextFiles limits or add ignored directories if needed.`, "warning");
304
+
305
+ const result = await ctx.ui.custom<PickResult>((tui, theme, _kb, done) => {
306
+ let cursorIndex = 0;
307
+ let scrollOffset = 0;
308
+ let cachedLines: string[] | undefined;
309
+ const maxTreeRows = 24;
310
+
311
+ function invalidate(): void {
312
+ cachedLines = undefined;
313
+ tui.requestRender();
314
+ }
315
+
316
+ function visibleRows(): VisiblePickerNode[] {
317
+ return collectVisibleNodes(root);
318
+ }
319
+
320
+ function ensureCursorVisible(): void {
321
+ const rows = visibleRows();
322
+ cursorIndex = Math.min(Math.max(cursorIndex, 0), Math.max(0, rows.length - 1));
323
+ if (cursorIndex < scrollOffset) scrollOffset = cursorIndex;
324
+ if (cursorIndex >= scrollOffset + maxTreeRows) scrollOffset = cursorIndex - maxTreeRows + 1;
325
+ scrollOffset = Math.min(Math.max(scrollOffset, 0), Math.max(0, rows.length - maxTreeRows));
326
+ }
327
+
328
+ function loadPickerDescendants(dirNode: PickerNode): void {
329
+ loadPickerChildren(ctx.cwd, controller, dirNode, tree.stats);
330
+ for (const child of dirNode.children) {
331
+ if (child.type === "dir") loadPickerDescendants(child);
332
+ }
333
+ }
334
+
335
+ function toggleNode(node: PickerNode): void {
336
+ if (node.type === "dir") {
337
+ loadPickerDescendants(node);
338
+ scanLimitMessage = formatScanLimitMessage(tree.stats);
339
+ }
340
+ const paths = collectFilePaths(node);
341
+ if (paths.length === 0) return;
342
+ const allSelected = paths.every((path) => selected.has(path));
343
+ for (const path of paths) {
344
+ if (allSelected) selected.delete(path);
345
+ else selected.add(path);
346
+ }
347
+ }
348
+
349
+ function handleInput(data: string): void {
350
+ const rows = visibleRows();
351
+ const current = rows[cursorIndex]?.node;
352
+
353
+ if (matchesKey(data, Key.up)) {
354
+ cursorIndex = Math.max(0, cursorIndex - 1);
355
+ ensureCursorVisible();
356
+ invalidate();
357
+ return;
358
+ }
359
+ if (matchesKey(data, Key.down)) {
360
+ cursorIndex = Math.min(rows.length - 1, cursorIndex + 1);
361
+ ensureCursorVisible();
362
+ invalidate();
363
+ return;
364
+ }
365
+ if (matchesKey(data, Key.right)) {
366
+ if (current?.type === "dir") {
367
+ loadPickerChildren(ctx.cwd, controller, current, tree.stats);
368
+ scanLimitMessage = formatScanLimitMessage(tree.stats);
369
+ current.expanded = true;
370
+ }
371
+ ensureCursorVisible();
372
+ invalidate();
373
+ return;
374
+ }
375
+ if (matchesKey(data, Key.left)) {
376
+ if (current?.type === "dir" && current.expanded) {
377
+ current.expanded = false;
378
+ } else if (current?.parent) {
379
+ const parentIndex = rows.findIndex((row) => row.node === current.parent);
380
+ if (parentIndex >= 0) cursorIndex = parentIndex;
381
+ }
382
+ ensureCursorVisible();
383
+ invalidate();
384
+ return;
385
+ }
386
+ if (matchesKey(data, Key.space)) {
387
+ if (current) toggleNode(current);
388
+ invalidate();
389
+ return;
390
+ }
391
+ if (data === "d" || data === "D") {
392
+ selected.clear();
393
+ invalidate();
394
+ return;
395
+ }
396
+ if (matchesKey(data, Key.enter)) {
397
+ done({ confirmed: true, selectedPaths: [...selected].sort((left, right) => left.localeCompare(right)) });
398
+ return;
399
+ }
400
+ if (matchesKey(data, Key.escape)) {
401
+ done({ confirmed: false, selectedPaths: [] });
402
+ }
403
+ }
404
+
405
+ function renderNode(row: VisiblePickerNode, isCursor: boolean, width: number): string {
406
+ const { node, depth } = row;
407
+ const state = selectedState(node, selected);
408
+ const box = state === "all" ? theme.fg("success", "☑") : state === "partial" ? theme.fg("warning", "◩") : theme.fg("dim", "☐");
409
+ const cursor = isCursor ? theme.fg("accent", "> ") : " ";
410
+ const indent = " ".repeat(depth);
411
+ const icon = node.type === "dir" ? (node.expanded ? "▾" : "▸") : " ";
412
+ const name = node.type === "dir" ? `${node.name}/` : node.name;
413
+ const label = isCursor ? theme.fg("accent", name) : node.type === "dir" ? theme.fg("text", name) : theme.fg("muted", name);
414
+ return truncateToWidth(`${cursor}${indent}${box} ${icon} ${label}`, width);
415
+ }
416
+
417
+ function render(width: number): string[] {
418
+ if (cachedLines) return cachedLines;
419
+ ensureCursorVisible();
420
+ const rows = visibleRows();
421
+ const visible = rows.slice(scrollOffset, scrollOffset + maxTreeRows);
422
+ const selectedCount = selected.size;
423
+ const totalFiles = collectFilePaths(root).length;
424
+ const lines: string[] = [];
425
+ const add = (line: string) => lines.push(truncateToWidth(line, width));
426
+
427
+ add(theme.fg("accent", theme.bold("Pick moonpi context files")));
428
+ add(theme.fg("dim", `${selectedCount}/${totalFiles} files selected for prompt injection`));
429
+ if (scanLimitMessage) add(theme.fg("warning", scanLimitMessage));
430
+ add(theme.fg("dim", "↑/↓ move • ←/→ close/open • Space select • D deselect all • Enter confirm • Esc cancel"));
431
+ lines.push("");
432
+
433
+ for (let i = 0; i < visible.length; i += 1) {
434
+ const absoluteIndex = scrollOffset + i;
435
+ const row = visible[i];
436
+ if (row) lines.push(renderNode(row, absoluteIndex === cursorIndex, width));
437
+ }
438
+
439
+ if (rows.length > maxTreeRows) {
440
+ const end = Math.min(rows.length, scrollOffset + maxTreeRows);
441
+ lines.push("");
442
+ add(theme.fg("dim", `Showing ${scrollOffset + 1}-${end} of ${rows.length}`));
443
+ }
444
+
445
+ cachedLines = lines;
446
+ return lines;
447
+ }
448
+
449
+ return { render, invalidate, handleInput };
450
+ });
451
+
452
+ if (!result.confirmed) {
453
+ ctx.ui.notify("moonpi context selection cancelled", "info");
454
+ return;
455
+ }
456
+
457
+ controller.state.selectedContextFilePaths = result.selectedPaths;
458
+ controller.persist();
459
+ const limitSuffix = scanLimitMessage ? ` (${scanLimitMessage})` : "";
460
+ const summary = result.selectedPaths.length === 0 ? `No files selected for prompt injection.${limitSuffix}` : `Selected ${result.selectedPaths.length} context file(s).${limitSuffix}`;
461
+ ctx.ui.notify(summary, "info");
462
+ },
463
+ });
464
+
465
+ pi.registerCommand("context", {
466
+ description: "Show files selected for prompt injection",
467
+ handler: async (_args, ctx) => {
468
+ if (!controller.config.contextFiles.enabled) {
469
+ ctx.ui.notify("moonpi context file injection is disabled in /moonpi:settings.", "warning");
470
+ return;
471
+ }
472
+
473
+ const paths = getEffectiveSelectedContextFilePaths(ctx.cwd, controller);
474
+ const isManual = controller.state.selectedContextFilePaths !== undefined;
475
+
476
+ if (paths.length === 0) {
477
+ ctx.ui.notify(isManual ? "No files selected for prompt injection. Use /pick to select files." : "No default context files found. Use /pick to select files.", "info");
478
+ return;
479
+ }
480
+
481
+ const fileList = paths.map((p) => ` ${p}`).join("\n");
482
+ const source = isManual ? "manually selected with /pick" : "auto-discovered (use /pick to change)";
483
+ ctx.ui.notify(`${paths.length} file(s) ${source}:\n${fileList}`, "info");
484
+ },
485
+ });
486
+
487
+ pi.on("session_start", async (_event, ctx) => {
488
+ controller.restoreFromSession(ctx);
489
+ const discovery = controller.state.selectedContextFilePaths === undefined ? findDefaultContextFilePaths(ctx.cwd, controller) : undefined;
490
+ const paths = discovery ? discovery.paths : getEffectiveSelectedContextFilePaths(ctx.cwd, controller);
491
+ const scanLimitMessage = discovery ? formatScanLimitMessage(discovery.stats) : undefined;
492
+ if (paths.length === 0) {
493
+ if (scanLimitMessage) ctx.ui.notify(`moonpi default context file ${scanLimitMessage}.`, "warning");
494
+ return;
495
+ }
496
+ const fileList = paths.map((p) => ` ${p}`).join("\n");
497
+ const scanNotice = scanLimitMessage ? `\n\nNote: default context file ${scanLimitMessage}.` : "";
498
+ ctx.ui.notify(`moonpi context files selected for injection (/pick to change):\n${fileList}${scanNotice}`, "info");
499
+ });
500
+
501
+ pi.on("before_agent_start", async (event) => {
502
+ const files = loadContextFiles(event.systemPromptOptions.cwd, controller);
503
+ if (files.length === 0) return undefined;
504
+ const rendered = files
505
+ .map((file) => `<context-file path="${escapeAttribute(file.relativePath)}">\n${file.content}\n</context-file>`)
506
+ .join("\n\n");
507
+
508
+ return {
509
+ systemPrompt: `${event.systemPrompt}
510
+
511
+ ## Project Context Files
512
+
513
+ The files selected with /pick are injected below. Keep relevant README.md, SPECS.md, SPRINT.md, and other selected project documents up to date when your work changes setup, behavior, commands, architecture, or project expectations.
514
+
515
+ ${rendered}`,
516
+ };
517
+ });
518
+ }