@flint-dev/tui 0.2.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.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @flint-dev/tui
2
+
3
+ Terminal UI for Flint app servers.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ flint-tui
9
+ ```
10
+
11
+ Optional environment variables:
12
+
13
+ - `FLINT_PROJECT` working directory override (defaults to current directory)
14
+ - `FLINT_APP_SERVER_COMMAND` app server command (defaults to `claude-app-server`)
15
+ - `FLINT_APP_SERVER_ARGS` space-delimited args forwarded to app server
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import "../src/index.ts";
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@flint-dev/tui",
3
+ "version": "0.2.0",
4
+ "description": "Terminal UI for chatting with Flint app servers",
5
+ "license": "MIT",
6
+ "author": "Aaron Escalona",
7
+ "files": [
8
+ "src",
9
+ "bin",
10
+ "README.md"
11
+ ],
12
+ "bin": {
13
+ "flint-tui": "./bin/flint-tui.js"
14
+ },
15
+ "type": "module",
16
+ "main": "src/index.ts",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "start": "bun run src/index.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@flint-dev/claude-app-server": "^0.2.2",
26
+ "@flint-dev/sdk": "^0.2.0",
27
+ "@mariozechner/pi-tui": "^0.50.1",
28
+ "chalk": "^5.5.0"
29
+ }
30
+ }
@@ -0,0 +1,104 @@
1
+ import { Chalk } from "chalk";
2
+
3
+ const chalk = new Chalk({ level: 3 });
4
+
5
+ /** Truncate a string to a maximum length, adding an ellipsis if needed. */
6
+ export function truncate(str: string, max: number): string {
7
+ if (!str || max <= 0) return "";
8
+ return str.length > max ? str.slice(0, max) + "…" : str;
9
+ }
10
+
11
+ /** Format a path for display, showing relative path if within cwd. */
12
+ function formatDisplayPath(filePath: string): string {
13
+ if (!filePath) return "";
14
+ const prefix = process.cwd() + "/";
15
+ if (filePath.startsWith(prefix)) return filePath.slice(prefix.length);
16
+ return filePath;
17
+ }
18
+
19
+ /** Extract the primary argument from a tool input for display. */
20
+ export function extractPrimaryArg(name: string, input: unknown): string {
21
+ const i = (input ?? {}) as Record<string, unknown>;
22
+ switch (name.toLowerCase()) {
23
+ case "bash":
24
+ return String(i.command ?? "");
25
+ case "read":
26
+ case "write":
27
+ case "edit":
28
+ return formatDisplayPath(String(i.file_path ?? ""));
29
+ case "glob":
30
+ case "grep":
31
+ return String(i.pattern ?? "");
32
+ case "websearch":
33
+ return truncate(String(i.query ?? ""), 50);
34
+ case "webfetch":
35
+ return truncate(String(i.url ?? "").replace(/^https?:\/\//, ""), 50);
36
+ case "askuserquestion": {
37
+ const questions = i.questions as Array<{ header?: string; question?: string }> | undefined;
38
+ if (questions && questions.length > 0) {
39
+ const first = questions[0]!;
40
+ return first.header ?? truncate(String(first.question ?? ""), 30);
41
+ }
42
+ return "";
43
+ }
44
+ default:
45
+ return "";
46
+ }
47
+ }
48
+
49
+ export function getDisplayName(name: string): string {
50
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
51
+ }
52
+
53
+ function hasError(result: unknown): boolean {
54
+ if (result === null || result === undefined) return false;
55
+ const r = result as Record<string, unknown>;
56
+ return r.is_error === true || (typeof r.error === "string" && r.error.length > 0);
57
+ }
58
+
59
+ function extractErrorMessage(result: unknown): string {
60
+ const r = (result ?? {}) as Record<string, unknown>;
61
+ if (typeof r.error === "string") return truncate(r.error, 80);
62
+ if (typeof r.content === "string") return truncate(r.content, 80);
63
+ return "unknown error";
64
+ }
65
+
66
+ export function formatToolLine(name: string, input: unknown, result: unknown): string {
67
+ const arg = extractPrimaryArg(name, input);
68
+ const isError = hasError(result);
69
+
70
+ const icon = isError ? chalk.red("✗") : chalk.green("✓");
71
+ const line = `${icon} ${getDisplayName(name)} ${chalk.dim(arg)}`;
72
+
73
+ if (isError) {
74
+ const errorText = extractErrorMessage(result);
75
+ return line + "\n " + chalk.red(errorText);
76
+ }
77
+
78
+ return line;
79
+ }
80
+
81
+ /**
82
+ * Calculate the line delta for an Edit operation.
83
+ * Returns a string like "+3", "-2", or "+0".
84
+ */
85
+ export function getEditLineDelta(oldString: string, newString: string): string {
86
+ const oldCount = oldString ? oldString.split("\n").length : 0;
87
+ const newCount = newString ? newString.split("\n").length : 0;
88
+ const delta = newCount - oldCount;
89
+ return delta >= 0 ? `+${delta}` : `${delta}`;
90
+ }
91
+
92
+ /**
93
+ * Format an inline diff: removed lines in red, added lines in green.
94
+ */
95
+ export function formatEditDiff(oldString: string, newString: string): string {
96
+ const output: string[] = [];
97
+ if (oldString) {
98
+ for (const line of oldString.split("\n")) output.push(chalk.red(` - ${line}`));
99
+ }
100
+ if (newString) {
101
+ for (const line of newString.split("\n")) output.push(chalk.green(` + ${line}`));
102
+ }
103
+ return output.join("\n");
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ import { Chalk } from "chalk";
2
+ import { AppServerClient, type AgentEvent } from "@flint-dev/sdk";
3
+ import {
4
+ TUI,
5
+ Text,
6
+ Editor,
7
+ Markdown,
8
+ Loader,
9
+ ProcessTerminal,
10
+ matchesKey,
11
+ } from "@mariozechner/pi-tui";
12
+ import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
13
+ import { join } from "path";
14
+ import { homedir } from "os";
15
+ import { mkdir, unlink } from "fs/promises";
16
+ import { processFileMentions } from "./mentions";
17
+ import {
18
+ truncate,
19
+ extractPrimaryArg,
20
+ getDisplayName,
21
+ formatToolLine,
22
+ getEditLineDelta,
23
+ formatEditDiff,
24
+ } from "./formatters";
25
+
26
+ // ── Theme ────────────────────────────────────────────────────────────────────
27
+
28
+ const chalk = new Chalk({ level: 3 });
29
+
30
+ const selectListTheme: SelectListTheme = {
31
+ selectedPrefix: (s: string) => chalk.blue(s),
32
+ selectedText: (s: string) => chalk.bold(s),
33
+ description: (s: string) => chalk.dim(s),
34
+ scrollInfo: (s: string) => chalk.dim(s),
35
+ noMatch: (s: string) => chalk.dim(s),
36
+ };
37
+
38
+ const editorTheme: EditorTheme = {
39
+ borderColor: (s: string) => chalk.dim(s),
40
+ selectList: selectListTheme,
41
+ };
42
+
43
+ const markdownTheme: MarkdownTheme = {
44
+ heading: (s: string) => chalk.bold.cyan(s),
45
+ link: (s: string) => chalk.blue(s),
46
+ linkUrl: (s: string) => chalk.dim(s),
47
+ code: (s: string) => chalk.yellow(s),
48
+ codeBlock: (s: string) => chalk.green(s),
49
+ codeBlockBorder: (s: string) => chalk.dim(s),
50
+ quote: (s: string) => chalk.italic(s),
51
+ quoteBorder: (s: string) => chalk.dim(s),
52
+ hr: (s: string) => chalk.dim(s),
53
+ listBullet: (s: string) => chalk.cyan(s),
54
+ bold: (s: string) => chalk.bold(s),
55
+ italic: (s: string) => chalk.italic(s),
56
+ strikethrough: (s: string) => chalk.strikethrough(s),
57
+ underline: (s: string) => chalk.underline(s),
58
+ };
59
+
60
+ // ── Config ───────────────────────────────────────────────────────────────────
61
+
62
+ const PROJECT = process.env["FLINT_PROJECT"] ?? process.cwd();
63
+ const APP_SERVER_COMMAND = process.env["FLINT_APP_SERVER_COMMAND"] ?? "claude-app-server";
64
+ const APP_SERVER_ARGS = (process.env["FLINT_APP_SERVER_ARGS"] ?? "")
65
+ .split(/\s+/)
66
+ .map((part) => part.trim())
67
+ .filter((part) => part.length > 0);
68
+ const IS_MAC = process.platform === "darwin";
69
+
70
+ // ── Image pasting (macOS only) ───────────────────────────────────────────────
71
+
72
+ async function cacheClipboardImage(threadId: string, imageCount: number): Promise<string | null> {
73
+ if (!IS_MAC) return null;
74
+
75
+ const cacheDir = join(homedir(), ".flint/image-cache", threadId);
76
+ await mkdir(cacheDir, { recursive: true });
77
+
78
+ const imgPath = join(cacheDir, `${imageCount + 1}.png`);
79
+
80
+ try {
81
+ const clipInfo = Bun.spawnSync(["osascript", "-e", "clipboard info"]).stdout.toString();
82
+ if (!clipInfo.includes("PNGf") && !clipInfo.includes("TIFF") && !clipInfo.includes("JPEG")) {
83
+ return null;
84
+ }
85
+
86
+ const script = `
87
+ set imgPath to POSIX file "${imgPath}"
88
+ set imgData to the clipboard as «class PNGf»
89
+ set fileRef to open for access imgPath with write permission
90
+ write imgData to fileRef
91
+ close access fileRef
92
+ `;
93
+ Bun.spawnSync(["osascript"], { stdin: Buffer.from(script) });
94
+
95
+ const file = Bun.file(imgPath);
96
+ if ((await file.exists()) && file.size > 0) return imgPath;
97
+ await unlink(imgPath);
98
+ } catch {
99
+ try {
100
+ await unlink(imgPath);
101
+ } catch {}
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // ── Main ─────────────────────────────────────────────────────────────────────
107
+
108
+ const client = new AppServerClient({
109
+ command: APP_SERVER_COMMAND,
110
+ args: APP_SERVER_ARGS,
111
+ cwd: PROJECT,
112
+ });
113
+
114
+ try {
115
+ await client.start();
116
+ } catch (err) {
117
+ console.error(`Could not start app server: ${err instanceof Error ? err.message : String(err)}`);
118
+ process.exit(1);
119
+ }
120
+
121
+ const threadId = await client.createThread();
122
+
123
+ // ── TUI setup ────────────────────────────────────────────────────────────────
124
+
125
+ const terminal = new ProcessTerminal();
126
+ const tui = new TUI(terminal);
127
+
128
+ const header = new Text(chalk.dim(`thread ${threadId.slice(0, 8)} • ${PROJECT}`), 1, 0);
129
+ tui.addChild(header);
130
+
131
+ // ── Image indicator state ────────────────────────────────────────────────────
132
+
133
+ let pendingImages: string[] = [];
134
+ let imageIndicator: Text | null = null;
135
+
136
+ function updateImageIndicator(): void {
137
+ if (pendingImages.length > 0) {
138
+ const indicatorText = pendingImages.map((_, i) => chalk.cyan(`[Image #${i + 1}]`)).join(" ");
139
+ if (!imageIndicator) {
140
+ imageIndicator = new Text(indicatorText, 0, 0);
141
+ const editorIdx = tui.children.indexOf(editor);
142
+ tui.children.splice(editorIdx, 0, imageIndicator);
143
+ } else {
144
+ imageIndicator.setText(indicatorText);
145
+ }
146
+ } else if (imageIndicator) {
147
+ tui.removeChild(imageIndicator);
148
+ imageIndicator = null;
149
+ }
150
+ tui.requestRender();
151
+ }
152
+
153
+ // ── Shared UI state ──────────────────────────────────────────────────────────
154
+
155
+ let isRunning = false;
156
+ let loader: Loader | null = null;
157
+ let textBuffer = "";
158
+ let currentMarkdown: Markdown | null = null;
159
+
160
+ const userMsgColor = chalk.hex("#b8b86e");
161
+ const NESTED_INDENT = ` ${chalk.dim("│")} `;
162
+
163
+ function flushText(): void {
164
+ if (textBuffer && currentMarkdown) {
165
+ currentMarkdown.setText(textBuffer);
166
+ tui.requestRender();
167
+ }
168
+ }
169
+
170
+ function resetRunState(): void {
171
+ flushText();
172
+ removeLoader();
173
+ textBuffer = "";
174
+ currentMarkdown = null;
175
+ isRunning = false;
176
+ editor.disableSubmit = false;
177
+ tui.requestRender();
178
+ }
179
+
180
+ function addUserMessage(text: string): void {
181
+ const formatted = text
182
+ .split("\n")
183
+ .map((line) => `${userMsgColor("▎")} ${userMsgColor.italic(line)}`)
184
+ .join("\n");
185
+ const msg = new Text(formatted, 1, 1);
186
+ tui.children.splice(tui.children.length - 1, 0, msg);
187
+ tui.requestRender();
188
+ }
189
+
190
+ function addMarkdownMessage(content: string): Markdown {
191
+ const md = new Markdown(content, 1, 1, markdownTheme);
192
+ tui.children.splice(tui.children.length - 1, 0, md);
193
+ tui.requestRender();
194
+ return md;
195
+ }
196
+
197
+ function removeLoader(): void {
198
+ if (loader) {
199
+ tui.removeChild(loader);
200
+ loader = null;
201
+ }
202
+ }
203
+
204
+ function startLoader(message: string): void {
205
+ removeLoader();
206
+ loader = new Loader(
207
+ tui,
208
+ (s: string) => chalk.cyan(s),
209
+ (s: string) => chalk.dim(s),
210
+ message,
211
+ );
212
+ tui.children.splice(tui.children.length - 1, 0, loader);
213
+ tui.requestRender();
214
+ }
215
+
216
+ function getInsertIndex(): number {
217
+ if (loader) {
218
+ const loaderIdx = tui.children.indexOf(loader);
219
+ if (loaderIdx !== -1) return loaderIdx;
220
+ }
221
+ return tui.children.length - 1;
222
+ }
223
+
224
+ // ── ToolTracker — single event handler, instantiated per run ─────────────────
225
+
226
+ class ToolTracker {
227
+ private running = new Map<string, Text>();
228
+ private pending = new Map<string, { name: string; input: unknown }>();
229
+ private nested = new Set<string>();
230
+ private subagents = new Map<string, Text>();
231
+
232
+ handleEvent(event: AgentEvent): void {
233
+ switch (event.type) {
234
+ case "text": {
235
+ if (!currentMarkdown) {
236
+ currentMarkdown = new Markdown("", 1, 1, markdownTheme);
237
+ tui.children.splice(getInsertIndex(), 0, currentMarkdown);
238
+ textBuffer = "";
239
+ }
240
+ textBuffer += event.delta;
241
+ currentMarkdown.setText(textBuffer);
242
+ tui.requestRender();
243
+ break;
244
+ }
245
+
246
+ case "tool_start": {
247
+ flushText();
248
+ textBuffer = "";
249
+ currentMarkdown = null;
250
+
251
+ const toolId = String(event.id);
252
+ const toolName = String(event.name);
253
+ const input = event.input;
254
+ const parentId = (event as { parentId?: string | null }).parentId;
255
+ this.pending.set(toolId, { name: toolName, input });
256
+
257
+ if (parentId) this.nested.add(toolId);
258
+
259
+ if (toolName === "Task") {
260
+ const taskText = new Text(
261
+ `${chalk.cyan("⋯")} ${chalk.bold("Task")} ${chalk.dim("running...")}`,
262
+ 1,
263
+ 1,
264
+ );
265
+ tui.children.splice(getInsertIndex(), 0, taskText);
266
+ this.subagents.set(toolId, taskText);
267
+ } else {
268
+ const arg = extractPrimaryArg(toolName, input);
269
+ const prefix = this.nested.has(toolId) ? NESTED_INDENT : "";
270
+ const toolText = new Text(
271
+ `${prefix}${chalk.cyan("⋯")} ${getDisplayName(toolName)} ${chalk.dim(arg)}`,
272
+ 1,
273
+ 0,
274
+ );
275
+ tui.children.splice(getInsertIndex(), 0, toolText);
276
+ this.running.set(toolId, toolText);
277
+ }
278
+ tui.requestRender();
279
+ break;
280
+ }
281
+
282
+ case "tool_end": {
283
+ const toolId = String(event.id);
284
+ const isError = Boolean(event.isError);
285
+ const result = event.result;
286
+ const toolInfo = this.pending.get(toolId);
287
+ const isNested = this.nested.has(toolId);
288
+
289
+ if (this.subagents.has(toolId)) {
290
+ const subText = this.subagents.get(toolId)!;
291
+ const icon = isError ? chalk.red("✗") : chalk.green("✓");
292
+ const description = toolInfo?.input
293
+ ? truncate(String((toolInfo.input as Record<string, unknown>).description ?? ""), 40)
294
+ : "";
295
+ subText.setText(
296
+ `${icon} ${chalk.bold("Task")} ${chalk.dim(description)} ${chalk.dim(isError ? "failed" : "completed")}`,
297
+ );
298
+ this.pending.delete(toolId);
299
+ this.subagents.delete(toolId);
300
+ tui.requestRender();
301
+ break;
302
+ }
303
+
304
+ const prefix = isNested ? NESTED_INDENT : "";
305
+ let toolLine: string;
306
+ const toolName = toolInfo?.name?.toLowerCase();
307
+
308
+ if (toolName === "edit") {
309
+ const inp = (toolInfo?.input ?? {}) as { old_string?: string; new_string?: string };
310
+ const delta = getEditLineDelta(inp.old_string ?? "", inp.new_string ?? "");
311
+ const baseLine = formatToolLine(toolInfo?.name ?? "edit", toolInfo?.input, result);
312
+ toolLine = prefix + baseLine + " " + chalk.yellow(delta);
313
+ if (!isError && (inp.old_string || inp.new_string)) {
314
+ const diffText = formatEditDiff(inp.old_string ?? "", inp.new_string ?? "");
315
+ const indentedDiff = diffText
316
+ .split("\n")
317
+ .map((line) => prefix + line)
318
+ .join("\n");
319
+ toolLine += "\n" + indentedDiff;
320
+ }
321
+ } else {
322
+ toolLine = prefix + formatToolLine(toolInfo?.name ?? "tool", toolInfo?.input, result);
323
+ }
324
+
325
+ if (this.running.has(toolId)) {
326
+ this.running.get(toolId)!.setText(toolLine);
327
+ this.running.delete(toolId);
328
+ }
329
+ this.pending.delete(toolId);
330
+ this.nested.delete(toolId);
331
+ tui.requestRender();
332
+ break;
333
+ }
334
+
335
+ case "reasoning":
336
+ if (!loader) startLoader("thinking… (esc to interrupt)");
337
+ else loader.setMessage("thinking… (esc to interrupt)");
338
+ break;
339
+
340
+ case "error":
341
+ removeLoader();
342
+ addMarkdownMessage(chalk.red(`Error: ${event.message}`));
343
+ break;
344
+ }
345
+ }
346
+ }
347
+
348
+ // ── Interrupt / cancel ───────────────────────────────────────────────────────
349
+
350
+ let lastCtrlCTime = 0;
351
+
352
+ function cancelStreaming(): boolean {
353
+ if (!isRunning) return false;
354
+
355
+ client.interrupt();
356
+
357
+ const interruptedMsg = new Text(`${chalk.dim("↳")} ${chalk.red("Interrupted")}`, 1, 0);
358
+ tui.children.splice(tui.children.length - 1, 0, interruptedMsg);
359
+
360
+ resetRunState();
361
+ return true;
362
+ }
363
+
364
+ // ── Editor ───────────────────────────────────────────────────────────────────
365
+
366
+ class FlintEditor extends Editor {
367
+ onInterrupt?: () => void;
368
+
369
+ override handleInput(data: string): void {
370
+ if (matchesKey(data, "ctrl+c")) {
371
+ const now = Date.now();
372
+ if (now - lastCtrlCTime < 1500) {
373
+ this.onInterrupt?.();
374
+ return;
375
+ }
376
+ lastCtrlCTime = now;
377
+
378
+ if (isRunning) cancelStreaming();
379
+ pendingImages = [];
380
+ updateImageIndicator();
381
+ this.setText("");
382
+ tui.requestRender();
383
+ return;
384
+ }
385
+ if (matchesKey(data, "escape")) {
386
+ cancelStreaming();
387
+ return;
388
+ }
389
+ if (matchesKey(data, "ctrl+v")) {
390
+ cacheClipboardImage(threadId, pendingImages.length).then((cachedPath) => {
391
+ if (cachedPath) {
392
+ pendingImages.push(cachedPath);
393
+ updateImageIndicator();
394
+ }
395
+ });
396
+ return;
397
+ }
398
+ super.handleInput(data);
399
+ }
400
+ }
401
+
402
+ const editor = new FlintEditor(tui, editorTheme);
403
+ tui.addChild(editor);
404
+ tui.setFocus(editor);
405
+
406
+ // ── runPrompt ────────────────────────────────────────────────────────────────
407
+
408
+ async function runPrompt(processed: string, displayText: string): Promise<void> {
409
+ isRunning = true;
410
+ editor.disableSubmit = true;
411
+
412
+ const tracker = new ToolTracker();
413
+ textBuffer = "";
414
+ currentMarkdown = null;
415
+
416
+ addUserMessage(displayText);
417
+ startLoader("thinking… (esc to interrupt)");
418
+
419
+ try {
420
+ for await (const event of client.prompt(processed)) {
421
+ tracker.handleEvent(event);
422
+ }
423
+ } catch (err) {
424
+ removeLoader();
425
+ addMarkdownMessage(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
426
+ } finally {
427
+ resetRunState();
428
+ }
429
+ }
430
+
431
+ // ── Submit handler ───────────────────────────────────────────────────────────
432
+
433
+ editor.onSubmit = (value: string) => {
434
+ if (isRunning) return;
435
+ const trimmed = value.trim();
436
+ if (!trimmed) return;
437
+
438
+ const processed = processFileMentions(trimmed, PROJECT);
439
+
440
+ let finalPrompt = processed;
441
+ if (pendingImages.length > 0) {
442
+ const imagePaths = pendingImages.map((p, i) => `[Image #${i + 1}]: ${p}`).join("\n");
443
+ finalPrompt = `${imagePaths}\n\n${processed}`;
444
+ pendingImages = [];
445
+ updateImageIndicator();
446
+ }
447
+
448
+ runPrompt(finalPrompt, trimmed);
449
+ };
450
+
451
+ // ── Graceful shutdown ────────────────────────────────────────────────────────
452
+
453
+ function shutdown(): void {
454
+ tui.stop();
455
+ client.close();
456
+ process.exit(0);
457
+ }
458
+
459
+ editor.onInterrupt = shutdown;
460
+
461
+ // ── Start ────────────────────────────────────────────────────────────────────
462
+
463
+ tui.start();
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2
+ import { resolve } from "path";
3
+
4
+ /** Check if a path is ignored by git */
5
+ export function isGitIgnored(path: string, cwd: string): boolean {
6
+ const result = Bun.spawnSync(["git", "check-ignore", "-q", path], { cwd });
7
+ return result.exitCode === 0;
8
+ }
9
+
10
+ /**
11
+ * Process @file mentions in input text.
12
+ * - Files: Full contents embedded in <file> tags
13
+ * - Directories: Listing embedded in <directory> tags
14
+ * Returns the processed prompt with file contents prepended.
15
+ */
16
+ export function processFileMentions(input: string, basePath: string): string {
17
+ const mentionRegex = /@([^\s]+)/g;
18
+ const mentions: Array<{ absolutePath: string; isDir: boolean }> = [];
19
+
20
+ // Find all @mentions
21
+ let match;
22
+ while ((match = mentionRegex.exec(input)) !== null) {
23
+ const filePath = match[1];
24
+ if (!filePath) continue;
25
+ const absolutePath = resolve(basePath, filePath);
26
+ if (existsSync(absolutePath) && !isGitIgnored(absolutePath, basePath)) {
27
+ mentions.push({
28
+ absolutePath,
29
+ isDir: statSync(absolutePath).isDirectory(),
30
+ });
31
+ }
32
+ }
33
+
34
+ if (mentions.length === 0) return input;
35
+
36
+ // Read and format contents
37
+ const contents = mentions
38
+ .map(({ absolutePath, isDir }) => {
39
+ try {
40
+ if (isDir) {
41
+ const entries = readdirSync(absolutePath, { withFileTypes: true });
42
+ const listing = entries
43
+ .filter((e) => !isGitIgnored(resolve(absolutePath, e.name), basePath))
44
+ .map((e) => `${e.name}${e.isDirectory() ? "/" : ""}`)
45
+ .join("\n");
46
+ return `<directory path="${absolutePath}">\n${listing}\n</directory>`;
47
+ } else {
48
+ const content = readFileSync(absolutePath, "utf-8");
49
+ return `<file path="${absolutePath}">\n${content}\n</file>`;
50
+ }
51
+ } catch {
52
+ return `<!-- Could not read ${absolutePath} -->`;
53
+ }
54
+ })
55
+ .join("\n\n");
56
+
57
+ // Remove @mentions from prompt, prepend contents
58
+ const cleanedPrompt = input.replace(mentionRegex, "").trim();
59
+ return `${contents}\n\n${cleanedPrompt}`;
60
+ }