@aaroncql/pim-agent 0.0.1

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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,234 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ThinkingLevelChangeEntry,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+ import { Paths } from "../../shared/Paths";
7
+ import type { GitState } from "./git";
8
+ import {
9
+ BG_BRIGHT_GREEN,
10
+ BG_BRIGHT_MAGENTA,
11
+ BG_BRIGHT_RED,
12
+ BG_BRIGHT_YELLOW,
13
+ BG_GRAY,
14
+ FG_BLACK,
15
+ FG_WHITE,
16
+ GIT_AHEAD_ICON,
17
+ GIT_BEHIND_ICON,
18
+ GIT_DIRTY_ICON,
19
+ GIT_ICON,
20
+ groupWidth,
21
+ renderLeftGroup,
22
+ renderRightGroup,
23
+ type Segment,
24
+ thinChevronLeft,
25
+ } from "./powerline";
26
+
27
+ function formatTokens(n: number): string {
28
+ if (n < 1000) {
29
+ return `${n}`;
30
+ }
31
+ if (n < 10_000) {
32
+ return `${(n / 1000).toFixed(1)}K`;
33
+ }
34
+ if (n < 1_000_000) {
35
+ return `${Math.round(n / 1000)}K`;
36
+ }
37
+ if (n < 10_000_000) {
38
+ return `${(n / 1_000_000).toFixed(1)}M`;
39
+ }
40
+ return `${Math.round(n / 1_000_000)}M`;
41
+ }
42
+
43
+ function gitSegment(state: GitState): Segment | null {
44
+ const { branch, dirty, ahead, behind } = state;
45
+ if (!branch) {
46
+ return null;
47
+ }
48
+ let text = `${GIT_ICON} ${branch}`;
49
+ if (dirty) {
50
+ text += ` ${GIT_DIRTY_ICON}`;
51
+ }
52
+ if (ahead > 0 || behind > 0) {
53
+ let arrows = " ";
54
+ if (ahead > 0) {
55
+ arrows += `${GIT_AHEAD_ICON}${ahead}`;
56
+ }
57
+ if (behind > 0) {
58
+ arrows += `${GIT_BEHIND_ICON}${behind}`;
59
+ }
60
+ text += arrows;
61
+ }
62
+ const bg =
63
+ behind > 0 ? BG_BRIGHT_RED : dirty ? BG_BRIGHT_YELLOW : BG_BRIGHT_GREEN;
64
+ return { text, fg: FG_BLACK, bg };
65
+ }
66
+
67
+ function ctxSegment(ctx: ExtensionContext): Segment | null {
68
+ const usage = ctx.getContextUsage();
69
+ if (!usage || usage.contextWindow === 0) {
70
+ return null;
71
+ }
72
+ const window = formatTokens(usage.contextWindow);
73
+ const text =
74
+ usage.percent === null
75
+ ? `?/${window}`
76
+ : `${usage.percent.toFixed(1)}%/${window}`;
77
+ const percent = usage.percent ?? 0;
78
+ const bg =
79
+ percent >= 70
80
+ ? BG_BRIGHT_RED
81
+ : percent > 40
82
+ ? BG_BRIGHT_YELLOW
83
+ : BG_BRIGHT_GREEN;
84
+ return { text, fg: FG_BLACK, bg };
85
+ }
86
+
87
+ function costSegment(cost: number): Segment | null {
88
+ if (cost <= 0) {
89
+ return null;
90
+ }
91
+ return {
92
+ text: `$${cost.toFixed(2)}`,
93
+ fg: FG_BLACK,
94
+ bg: BG_BRIGHT_MAGENTA,
95
+ };
96
+ }
97
+
98
+ const LEVEL_LABEL: Record<string, string> = { minimal: "min", medium: "med" };
99
+
100
+ function findLatestThinkingLevel(ctx: ExtensionContext): string {
101
+ const branch = ctx.sessionManager.getBranch();
102
+ for (let i = branch.length - 1; i >= 0; i--) {
103
+ const entry = branch[i]!;
104
+ if (entry.type === "thinking_level_change") {
105
+ return (entry as ThinkingLevelChangeEntry).thinkingLevel;
106
+ }
107
+ }
108
+ return "off";
109
+ }
110
+
111
+ function modelSegment(ctx: ExtensionContext): Segment | null {
112
+ const id = ctx.model?.id;
113
+ if (!id) {
114
+ return null;
115
+ }
116
+
117
+ if (ctx.model?.reasoning) {
118
+ const level = findLatestThinkingLevel(ctx);
119
+ const label = LEVEL_LABEL[level] ?? level;
120
+ return {
121
+ text: `${id} ${thinChevronLeft(BG_GRAY, FG_WHITE)} ${label}`,
122
+ fg: FG_WHITE,
123
+ bg: BG_GRAY,
124
+ };
125
+ }
126
+
127
+ return { text: id, fg: FG_WHITE, bg: BG_GRAY };
128
+ }
129
+
130
+ function compact<T>(items: readonly (T | null)[]): T[] {
131
+ return items.filter((x): x is T => x !== null);
132
+ }
133
+
134
+ function totalWidth(
135
+ left: readonly Segment[],
136
+ right: readonly Segment[]
137
+ ): number {
138
+ const gap = left.length > 0 && right.length > 0 ? 1 : 0;
139
+ return groupWidth(left) + groupWidth(right) + gap;
140
+ }
141
+
142
+ function fitLine(line: string, width: number): string {
143
+ if (width <= 0) {
144
+ return "";
145
+ }
146
+ return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "");
147
+ }
148
+
149
+ const bold = (s: string): string => `\x1b[1m${s}\x1b[22m`;
150
+
151
+ function formatCwd(path: string): string {
152
+ if (path === "/" || path === "~") {
153
+ return bold(path);
154
+ }
155
+ const parts = path.split("/");
156
+ const last = parts.pop()!;
157
+ const abbrParent = parts
158
+ .map((p) => {
159
+ if (p === "" || p === "~") {
160
+ return p;
161
+ }
162
+ return p.startsWith(".") && p.length > 1 ? p.slice(0, 2) : p[0]!;
163
+ })
164
+ .join("/");
165
+ return `${abbrParent}/${bold(last)}`;
166
+ }
167
+
168
+ export function renderFooterLine(
169
+ width: number,
170
+ ctx: ExtensionContext,
171
+ gitState: GitState,
172
+ cost: number
173
+ ): string {
174
+ const cwd: Segment = {
175
+ text: formatCwd(Paths.abbreviateHome(ctx.sessionManager.getCwd())),
176
+ fg: FG_WHITE,
177
+ bg: BG_GRAY,
178
+ };
179
+ const branch = gitSegment(gitState);
180
+ const costSeg = costSegment(cost);
181
+ const ctxSeg = ctxSegment(ctx);
182
+ const model = modelSegment(ctx);
183
+
184
+ const fullLeft = compact([cwd, branch]);
185
+ const fullLeftWidth = groupWidth(fullLeft);
186
+ const candidates: readonly { left: Segment[]; right: Segment[] }[] = [
187
+ { left: fullLeft, right: compact([costSeg, ctxSeg, model]) },
188
+ { left: fullLeft, right: compact([costSeg, ctxSeg]) },
189
+ { left: fullLeft, right: compact([ctxSeg]) },
190
+ { left: [cwd], right: compact([ctxSeg]) },
191
+ { left: [cwd], right: [] },
192
+ ];
193
+
194
+ let chosen = candidates[candidates.length - 1]!;
195
+ let chosenLeftWidth = groupWidth(chosen.left);
196
+ let chosenRightWidth = groupWidth(chosen.right);
197
+ for (const c of candidates) {
198
+ const lw = c.left === fullLeft ? fullLeftWidth : groupWidth(c.left);
199
+ const rw = groupWidth(c.right);
200
+ const gapWidth = c.left.length > 0 && c.right.length > 0 ? 1 : 0;
201
+ if (lw + rw + gapWidth <= width) {
202
+ chosen = c;
203
+ chosenLeftWidth = lw;
204
+ chosenRightWidth = rw;
205
+ break;
206
+ }
207
+ }
208
+
209
+ let left = chosen.left;
210
+ let leftWidth = chosenLeftWidth;
211
+ const requiredWidth = totalWidth(left, chosen.right);
212
+ if (requiredWidth > width && left.length > 0) {
213
+ const overflow = requiredWidth - width;
214
+ const newCwdWidth = Math.max(0, visibleWidth(left[0]!.text) - overflow);
215
+ const truncated: Segment = {
216
+ ...left[0]!,
217
+ text: truncateToWidth(left[0]!.text, newCwdWidth, "…"),
218
+ };
219
+ left = [truncated, ...left.slice(1)];
220
+ leftWidth =
221
+ leftWidth -
222
+ visibleWidth(chosen.left[0]!.text) +
223
+ visibleWidth(truncated.text);
224
+ }
225
+
226
+ const gap =
227
+ left.length > 0 && chosen.right.length > 0
228
+ ? Math.max(1, width - leftWidth - chosenRightWidth)
229
+ : 0;
230
+ return fitLine(
231
+ renderLeftGroup(left) + " ".repeat(gap) + renderRightGroup(chosen.right),
232
+ width
233
+ );
234
+ }
@@ -0,0 +1,171 @@
1
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, describe, expect, test } from "bun:test";
5
+ import { findFiles } from "./glob";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const tempRoot = async (): Promise<string> => {
10
+ const root = await mkdtemp(join(tmpdir(), "pim-glob-tool-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ afterAll(async () => {
16
+ await Promise.all(
17
+ tempRoots.map((root) => rm(root, { force: true, recursive: true }))
18
+ );
19
+ });
20
+
21
+ const defaultScanOptions = {
22
+ includeDotfiles: false,
23
+ includeIgnored: false,
24
+ } as const;
25
+
26
+ describe("findFiles", () => {
27
+ test("sorts by recency desc with path-asc tiebreak when mtimes are equal", async () => {
28
+ const root = await tempRoot();
29
+ const older = join(root, "older.ts");
30
+ const tieB = join(root, "b.ts");
31
+ const tieA = join(root, "a.ts");
32
+
33
+ await writeFile(older, "", "utf8");
34
+ await writeFile(tieA, "", "utf8");
35
+ await writeFile(tieB, "", "utf8");
36
+
37
+ await utimes(
38
+ older,
39
+ new Date("2024-01-01T00:00:00Z"),
40
+ new Date("2024-01-01T00:00:00Z")
41
+ );
42
+ await utimes(
43
+ tieA,
44
+ new Date("2024-01-02T00:00:00Z"),
45
+ new Date("2024-01-02T00:00:00Z")
46
+ );
47
+ await utimes(
48
+ tieB,
49
+ new Date("2024-01-02T00:00:00Z"),
50
+ new Date("2024-01-02T00:00:00Z")
51
+ );
52
+
53
+ const matches = await findFiles(root, "**/*.ts", defaultScanOptions);
54
+
55
+ expect(matches.map((match) => match.path)).toEqual([tieA, tieB, older]);
56
+ });
57
+
58
+ test("respects gitignore, dotfiles, and the always-ignored defaults", async () => {
59
+ const root = await tempRoot();
60
+ const src = join(root, "src");
61
+ const ignored = join(src, "ignored.ts");
62
+ const kept = join(src, "kept.ts");
63
+ const nodeModules = join(root, "node_modules", "pkg", "x.ts");
64
+ const dot = join(root, ".secret", "x.ts");
65
+
66
+ await mkdir(src, { recursive: true });
67
+ await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
68
+ await mkdir(join(root, ".secret"), { recursive: true });
69
+ await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
70
+ await writeFile(ignored, "", "utf8");
71
+ await writeFile(kept, "", "utf8");
72
+ await writeFile(nodeModules, "", "utf8");
73
+ await writeFile(dot, "", "utf8");
74
+
75
+ const matches = await findFiles(root, "**/*.ts", defaultScanOptions);
76
+
77
+ expect(matches.map((match) => match.path)).toEqual([kept]);
78
+ });
79
+
80
+ test("can include dotfiles and ignored paths", async () => {
81
+ const root = await tempRoot();
82
+ const kept = join(root, "kept.ts");
83
+ const ignored = join(root, "ignored.ts");
84
+ const dot = join(root, ".secret", "x.ts");
85
+
86
+ await mkdir(join(root, ".secret"), { recursive: true });
87
+ await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
88
+ await writeFile(kept, "", "utf8");
89
+ await writeFile(ignored, "", "utf8");
90
+ await writeFile(dot, "", "utf8");
91
+
92
+ const matches = await findFiles(root, "**/*.ts", {
93
+ includeDotfiles: true,
94
+ includeIgnored: true,
95
+ });
96
+
97
+ expect(matches.map((match) => match.path).sort()).toEqual(
98
+ [dot, ignored, kept].sort()
99
+ );
100
+ });
101
+
102
+ test("filters by glob pattern extension", async () => {
103
+ const root = await tempRoot();
104
+ const ts = join(root, "a.ts");
105
+ const md = join(root, "a.md");
106
+
107
+ await writeFile(ts, "", "utf8");
108
+ await writeFile(md, "", "utf8");
109
+
110
+ const matches = await findFiles(root, "**/*.ts", defaultScanOptions);
111
+
112
+ expect(matches.map((match) => match.path)).toEqual([ts]);
113
+ });
114
+
115
+ test("excludes a single glob pattern", async () => {
116
+ const root = await tempRoot();
117
+ const source = join(root, "src", "app.ts");
118
+ const test = join(root, "src", "app.test.ts");
119
+
120
+ await mkdir(join(root, "src"), { recursive: true });
121
+ await writeFile(source, "", "utf8");
122
+ await writeFile(test, "", "utf8");
123
+
124
+ const matches = await findFiles(root, "**/*.ts", {
125
+ ...defaultScanOptions,
126
+ exclude: ["**/*.test.ts"],
127
+ });
128
+
129
+ expect(matches.map((match) => match.path)).toEqual([source]);
130
+ });
131
+
132
+ test("excludes multiple glob patterns", async () => {
133
+ const root = await tempRoot();
134
+ const source = join(root, "src", "app.ts");
135
+ const test = join(root, "src", "app.test.ts");
136
+ const generated = join(root, "src", "generated", "types.ts");
137
+
138
+ await mkdir(join(root, "src", "generated"), { recursive: true });
139
+ await writeFile(source, "", "utf8");
140
+ await writeFile(test, "", "utf8");
141
+ await writeFile(generated, "", "utf8");
142
+
143
+ const matches = await findFiles(root, "**/*.ts", {
144
+ ...defaultScanOptions,
145
+ exclude: ["**/*.test.ts", "src/generated/**"],
146
+ });
147
+
148
+ expect(matches.map((match) => match.path)).toEqual([source]);
149
+ });
150
+
151
+ test("throws an actionable error when the path does not exist", async () => {
152
+ const root = await tempRoot();
153
+ const missing = join(root, "nope");
154
+
155
+ await expect(
156
+ findFiles(missing, "**/*", defaultScanOptions)
157
+ ).rejects.toThrow(
158
+ `Path not found: ${missing}. Use glob to locate the file or directory, or verify the path.`
159
+ );
160
+ });
161
+
162
+ test("throws an actionable error when path is a file, not a directory", async () => {
163
+ const root = await tempRoot();
164
+ const file = join(root, "notes.txt");
165
+ await writeFile(file, "hello", "utf8");
166
+
167
+ await expect(findFiles(file, "**/*", defaultScanOptions)).rejects.toThrow(
168
+ `Glob path must be a directory: ${file}. Drop "path" and put the filename in "pattern", or use the read tool to inspect a single file.`
169
+ );
170
+ });
171
+ });
@@ -0,0 +1,34 @@
1
+ import { FileScanner, type FileScanOptions } from "../../shared/FileScanner";
2
+ import { FsErrors } from "../../shared/FsErrors";
3
+
4
+ export type GlobMatch = {
5
+ readonly path: string;
6
+ readonly mtime: number;
7
+ };
8
+
9
+ export type GlobScanOptions = FileScanOptions;
10
+
11
+ export async function findFiles(
12
+ root: string,
13
+ pattern: string,
14
+ options: GlobScanOptions
15
+ ): Promise<readonly GlobMatch[]> {
16
+ const metadata = await FsErrors.statOrThrow(root);
17
+
18
+ if (!metadata.isDirectory()) {
19
+ throw new Error(
20
+ `Glob path must be a directory: ${root}. Drop "path" and put the filename in "pattern", or use the read tool to inspect a single file.`
21
+ );
22
+ }
23
+
24
+ const paths = await FileScanner.scan(root, pattern, options);
25
+ const matches: GlobMatch[] = paths.map((path) => ({
26
+ path,
27
+ mtime: Bun.file(path).lastModified,
28
+ }));
29
+
30
+ return matches.sort(
31
+ (left, right) =>
32
+ right.mtime - left.mtime || left.path.localeCompare(right.path)
33
+ );
34
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ AgentToolResult,
4
+ ExtensionAPI,
5
+ Theme,
6
+ ToolDefinition,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import registerGlob from "./index";
9
+
10
+ const stubTheme = {
11
+ bold: (text: string) => text,
12
+ fg: (_color: string, text: string) => text,
13
+ } as unknown as Theme;
14
+
15
+ function registeredTool(): ToolDefinition {
16
+ let tool: ToolDefinition | undefined;
17
+ registerGlob({
18
+ registerTool(def: ToolDefinition): void {
19
+ tool = def;
20
+ },
21
+ } as unknown as ExtensionAPI);
22
+
23
+ if (tool === undefined) {
24
+ throw new Error("glob tool was not registered");
25
+ }
26
+ return tool;
27
+ }
28
+
29
+ describe("glob tool renderer", () => {
30
+ test("updates the visible call title with the file count when the result renders", () => {
31
+ const tool = registeredTool();
32
+ const args = { pattern: "**/*.ts" };
33
+ const state = {};
34
+ const callContext = {
35
+ args,
36
+ toolCallId: "glob-1",
37
+ invalidate: () => {},
38
+ lastComponent: undefined,
39
+ state,
40
+ cwd: "/repo",
41
+ executionStarted: true,
42
+ argsComplete: true,
43
+ isPartial: false,
44
+ expanded: false,
45
+ showImages: true,
46
+ isError: false,
47
+ };
48
+ const callComponent = tool.renderCall!(args, stubTheme, callContext);
49
+
50
+ expect(callComponent.render(120).join("\n")).not.toContain("(2 files)");
51
+
52
+ const result: AgentToolResult<unknown> = {
53
+ content: [{ type: "text", text: "src/a.ts\nsrc/b.ts" }],
54
+ details: { fileCount: 2 },
55
+ };
56
+ tool.renderResult!(
57
+ result,
58
+ { expanded: false, isPartial: false },
59
+ stubTheme,
60
+ {
61
+ ...callContext,
62
+ lastComponent: undefined,
63
+ }
64
+ );
65
+
66
+ expect(callComponent.render(120).join("\n")).toContain("(2 files)");
67
+ });
68
+ });
@@ -0,0 +1,136 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Paths } from "../../shared/Paths";
3
+ import {
4
+ Renderer,
5
+ type StatefulToolCallTitleContext,
6
+ type StatefulToolCallTitleState,
7
+ } from "../../shared/Renderer";
8
+ import { Tools } from "../../shared/Tools";
9
+ import { findFiles } from "./glob";
10
+ import { formatTitle, renderFiles } from "./render";
11
+ import {
12
+ GLOB_HEAD_LIMIT_MAX,
13
+ type GlobInput,
14
+ type GlobPathFormat,
15
+ globSchema,
16
+ } from "./schema";
17
+
18
+ const PREVIEW_LINES = 10;
19
+ const DEFAULT_PATH_FORMAT: GlobPathFormat = "relative";
20
+
21
+ type GlobCallState = StatefulToolCallTitleState & {
22
+ fileCount?: number;
23
+ };
24
+
25
+ type GlobRenderContext = StatefulToolCallTitleContext & {
26
+ readonly args?: GlobInput;
27
+ readonly cwd: string;
28
+ };
29
+
30
+ function renderTitle(
31
+ input: Partial<GlobInput>,
32
+ theme: Theme,
33
+ context: GlobRenderContext
34
+ ) {
35
+ const state = context.state as GlobCallState;
36
+ const title = formatTitle({
37
+ pattern: input.pattern,
38
+ path: input.path,
39
+ cwd: context.cwd,
40
+ fileCount: state.fileCount,
41
+ });
42
+ return Renderer.renderStatefulToolCallTitle({
43
+ label: "Glob",
44
+ title,
45
+ theme,
46
+ context,
47
+ });
48
+ }
49
+
50
+ export default function (pi: ExtensionAPI): void {
51
+ Tools.register(pi, {
52
+ name: "glob",
53
+ label: "glob",
54
+ description:
55
+ "Find files by glob pattern under a directory, sorted newest first. Skips gitignored paths and dotfiles unless requested. Use glob to enumerate files instead of bash with find, fd, ls -R, or similar.",
56
+ parameters: globSchema,
57
+ renderShell: "self",
58
+ executionMode: "parallel",
59
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
60
+ const {
61
+ pattern,
62
+ path,
63
+ exclude,
64
+ includeDotfiles,
65
+ includeIgnored,
66
+ pathFormat,
67
+ headLimit,
68
+ } = params as GlobInput;
69
+
70
+ if (signal?.aborted) {
71
+ throw new Error("Glob aborted before execution.");
72
+ }
73
+
74
+ const limit = headLimit ?? GLOB_HEAD_LIMIT_MAX;
75
+ const resolvedPathFormat = pathFormat ?? DEFAULT_PATH_FORMAT;
76
+ const absolutePath = Paths.resolve(path ?? ".", ctx.cwd);
77
+ const matches = await findFiles(absolutePath, pattern, {
78
+ exclude,
79
+ includeDotfiles: includeDotfiles ?? false,
80
+ includeIgnored: includeIgnored ?? false,
81
+ });
82
+ const outcome = renderFiles(matches, limit, {
83
+ cwd: ctx.cwd,
84
+ pathFormat: resolvedPathFormat,
85
+ });
86
+ const content: Array<{ type: "text"; text: string }> = [
87
+ { type: "text", text: outcome.body },
88
+ ];
89
+
90
+ if (outcome.truncated) {
91
+ content.push({
92
+ type: "text",
93
+ text: `[glob tool: showing ${outcome.visibleItems} of ${outcome.totalItems} entries; narrow the pattern or scope to a specific path to reduce results.]`,
94
+ });
95
+ }
96
+
97
+ return {
98
+ content,
99
+ details: {
100
+ absolutePath,
101
+ pattern,
102
+ exclude,
103
+ includeDotfiles: includeDotfiles ?? false,
104
+ includeIgnored: includeIgnored ?? false,
105
+ pathFormat: resolvedPathFormat,
106
+ fileCount: matches.length,
107
+ totalItems: outcome.totalItems,
108
+ visibleItems: outcome.visibleItems,
109
+ truncated: outcome.truncated,
110
+ },
111
+ };
112
+ },
113
+ renderCall(args, theme, context) {
114
+ return renderTitle((args ?? {}) as Partial<GlobInput>, theme, context);
115
+ },
116
+ renderResult(result, options, theme, context) {
117
+ const state = context.state as GlobCallState;
118
+ const details = result.details as
119
+ | { readonly fileCount?: number }
120
+ | undefined;
121
+
122
+ if (details?.fileCount !== undefined) {
123
+ state.fileCount = details.fileCount;
124
+ renderTitle(context.args ?? {}, theme, context);
125
+ }
126
+
127
+ return Renderer.renderBorderedResult({
128
+ result,
129
+ options,
130
+ theme,
131
+ context,
132
+ previewLines: PREVIEW_LINES,
133
+ });
134
+ },
135
+ });
136
+ }