@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,215 @@
1
+ import { FileScanner, type FileScanOptions } from "../../shared/FileScanner";
2
+ import { FsErrors } from "../../shared/FsErrors";
3
+ import { Lines } from "../../shared/Lines";
4
+
5
+ const MATCH_CONCURRENCY = 32;
6
+
7
+ export type GrepLine = {
8
+ readonly lineNumber: number;
9
+ readonly text: string;
10
+ };
11
+
12
+ export type GrepLineRange = {
13
+ readonly startLineNumber: number;
14
+ readonly endLineNumber: number;
15
+ };
16
+
17
+ export type GrepMatch = {
18
+ readonly filePath: string;
19
+ readonly mtime: number;
20
+ readonly lines: readonly GrepLine[];
21
+ readonly ranges: readonly GrepLineRange[];
22
+ readonly fileLines: readonly string[];
23
+ };
24
+
25
+ export type GrepMatcher = {
26
+ readonly regex: RegExp;
27
+ readonly matchAcrossLines: boolean;
28
+ };
29
+
30
+ export type GrepScanOptions = FileScanOptions;
31
+
32
+ export function buildMatcher(options: {
33
+ readonly pattern: string;
34
+ readonly caseInsensitive: boolean;
35
+ readonly matchAcrossLines: boolean;
36
+ }): GrepMatcher {
37
+ const flags = `${options.matchAcrossLines ? "s" : ""}${
38
+ options.caseInsensitive ? "i" : ""
39
+ }`;
40
+
41
+ try {
42
+ return {
43
+ regex: new RegExp(options.pattern, flags),
44
+ matchAcrossLines: options.matchAcrossLines,
45
+ };
46
+ } catch (error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ throw new Error(
49
+ `Invalid regular expression /${options.pattern}/${flags}: ${message}. Escape regex metacharacters for a literal search or simplify the pattern.`
50
+ );
51
+ }
52
+ }
53
+
54
+ export async function findMatches(
55
+ path: string,
56
+ glob: string | undefined,
57
+ matcher: GrepMatcher,
58
+ options: GrepScanOptions
59
+ ): Promise<readonly GrepMatch[]> {
60
+ const metadata = await FsErrors.statOrThrow(path);
61
+ const files = metadata.isFile()
62
+ ? [path]
63
+ : (await FileScanner.scan(path, glob ?? "**/*", options)).toSorted(
64
+ comparePaths
65
+ );
66
+ const results: GrepMatch[] = [];
67
+
68
+ for (let index = 0; index < files.length; index += MATCH_CONCURRENCY) {
69
+ const chunk = files.slice(index, index + MATCH_CONCURRENCY);
70
+ const chunkResults = await Promise.all(
71
+ chunk.map((filePath) => matchFile(filePath, matcher))
72
+ );
73
+
74
+ for (const match of chunkResults) {
75
+ if (match !== undefined) {
76
+ results.push(match);
77
+ }
78
+ }
79
+ }
80
+
81
+ return results;
82
+ }
83
+
84
+ async function matchFile(
85
+ filePath: string,
86
+ matcher: GrepMatcher
87
+ ): Promise<GrepMatch | undefined> {
88
+ const file = Bun.file(filePath);
89
+
90
+ if (await Lines.isBinary(file)) {
91
+ return undefined;
92
+ }
93
+
94
+ const content = Lines.normalize(await file.text());
95
+ const fileLines = Lines.split(content);
96
+ const ranges = matcher.matchAcrossLines
97
+ ? regexRanges(content, matcher.regex)
98
+ : matchLineByLine(fileLines, matcher.regex);
99
+
100
+ if (ranges.length === 0) {
101
+ return undefined;
102
+ }
103
+
104
+ return {
105
+ filePath,
106
+ mtime: file.lastModified,
107
+ lines: linesForRanges(fileLines, ranges),
108
+ ranges,
109
+ fileLines,
110
+ };
111
+ }
112
+
113
+ function matchLineByLine(
114
+ lines: readonly string[],
115
+ regex: RegExp
116
+ ): readonly GrepLineRange[] {
117
+ const ranges: GrepLineRange[] = [];
118
+
119
+ for (const [index, line] of lines.entries()) {
120
+ if (regex.test(line)) {
121
+ const lineNumber = index + 1;
122
+ ranges.push({ startLineNumber: lineNumber, endLineNumber: lineNumber });
123
+ }
124
+ }
125
+
126
+ return ranges;
127
+ }
128
+
129
+ function regexRanges(content: string, regex: RegExp): readonly GrepLineRange[] {
130
+ const globalRegex = new RegExp(regex.source, addFlag(regex.flags, "g"));
131
+ const ranges: GrepLineRange[] = [];
132
+
133
+ while (true) {
134
+ const match = globalRegex.exec(content);
135
+
136
+ if (match === null) {
137
+ break;
138
+ }
139
+
140
+ ranges.push(
141
+ lineRangeForOffsets(content, match.index, match.index + match[0].length)
142
+ );
143
+
144
+ if (match[0].length === 0) {
145
+ globalRegex.lastIndex += 1;
146
+ }
147
+ }
148
+
149
+ return ranges;
150
+ }
151
+
152
+ function addFlag(flags: string, flag: string): string {
153
+ return flags.includes(flag) ? flags : `${flags}${flag}`;
154
+ }
155
+
156
+ function lineRangeForOffsets(
157
+ content: string,
158
+ startOffset: number,
159
+ endOffset: number
160
+ ): GrepLineRange {
161
+ return {
162
+ startLineNumber: lineNumberForOffset(content, startOffset),
163
+ endLineNumber: lineNumberForOffset(
164
+ content,
165
+ Math.max(startOffset, endOffset - 1)
166
+ ),
167
+ };
168
+ }
169
+
170
+ function lineNumberForOffset(content: string, offset: number): number {
171
+ let lineNumber = 1;
172
+
173
+ for (let index = 0; index < offset && index < content.length; index += 1) {
174
+ if (content[index] === "\n") {
175
+ lineNumber += 1;
176
+ }
177
+ }
178
+
179
+ return lineNumber;
180
+ }
181
+
182
+ function linesForRanges(
183
+ fileLines: readonly string[],
184
+ ranges: readonly GrepLineRange[]
185
+ ): readonly GrepLine[] {
186
+ const seen = new Set<number>();
187
+ const lines: GrepLine[] = [];
188
+
189
+ for (const range of ranges) {
190
+ for (
191
+ let lineNumber = range.startLineNumber;
192
+ lineNumber <= range.endLineNumber;
193
+ lineNumber += 1
194
+ ) {
195
+ if (seen.has(lineNumber)) {
196
+ continue;
197
+ }
198
+
199
+ seen.add(lineNumber);
200
+ lines.push({ lineNumber, text: fileLines[lineNumber - 1] ?? "" });
201
+ }
202
+ }
203
+
204
+ return lines;
205
+ }
206
+
207
+ function comparePaths(left: string, right: string): number {
208
+ if (left < right) {
209
+ return -1;
210
+ }
211
+ if (left > right) {
212
+ return 1;
213
+ }
214
+ return 0;
215
+ }
@@ -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 registerGrep 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
+ registerGrep({
18
+ registerTool(def: ToolDefinition): void {
19
+ tool = def;
20
+ },
21
+ } as unknown as ExtensionAPI);
22
+
23
+ if (tool === undefined) {
24
+ throw new Error("grep tool was not registered");
25
+ }
26
+ return tool;
27
+ }
28
+
29
+ describe("grep 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: "alpha" };
33
+ const state = {};
34
+ const callContext = {
35
+ args,
36
+ toolCallId: "grep-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,158 @@
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 { buildMatcher, findMatches } from "./grep";
10
+ import { formatTitle, renderMatches } from "./render";
11
+ import {
12
+ GREP_HEAD_LIMIT_MAX,
13
+ type GrepInput,
14
+ type GrepOutputMode,
15
+ type GrepPathFormat,
16
+ grepSchema,
17
+ } from "./schema";
18
+
19
+ const PREVIEW_LINES = 10;
20
+ const DEFAULT_OUTPUT_MODE: GrepOutputMode = "files_with_matches";
21
+ const DEFAULT_PATH_FORMAT: GrepPathFormat = "relative";
22
+
23
+ type GrepCallState = StatefulToolCallTitleState & {
24
+ fileCount?: number;
25
+ };
26
+
27
+ type GrepRenderContext = StatefulToolCallTitleContext & {
28
+ readonly args?: GrepInput;
29
+ readonly cwd: string;
30
+ };
31
+
32
+ function renderTitle(
33
+ input: Partial<GrepInput>,
34
+ theme: Theme,
35
+ context: GrepRenderContext
36
+ ) {
37
+ const state = context.state as GrepCallState;
38
+ const title = formatTitle({
39
+ pattern: input.pattern,
40
+ path: input.path,
41
+ glob: input.glob,
42
+ cwd: context.cwd,
43
+ fileCount: state.fileCount,
44
+ });
45
+ return Renderer.renderStatefulToolCallTitle({
46
+ label: "Grep",
47
+ title,
48
+ theme,
49
+ context,
50
+ });
51
+ }
52
+
53
+ export default function (pi: ExtensionAPI): void {
54
+ Tools.register(pi, {
55
+ name: "grep",
56
+ label: "grep",
57
+ description:
58
+ "Search UTF-8 text files with a JavaScript regex. Directory scans skip binary files, gitignored paths, and dotfiles unless requested; direct file paths are always searched. Use grep to search file contents instead of bash with grep, rg, ag, find -exec, or similar.",
59
+ parameters: grepSchema,
60
+ renderShell: "self",
61
+ executionMode: "parallel",
62
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
63
+ const {
64
+ pattern,
65
+ path,
66
+ glob,
67
+ exclude,
68
+ outputMode,
69
+ matchAcrossLines,
70
+ context,
71
+ includeDotfiles,
72
+ includeIgnored,
73
+ pathFormat,
74
+ caseInsensitive,
75
+ headLimit,
76
+ } = params as GrepInput;
77
+
78
+ if (signal?.aborted) {
79
+ throw new Error("Grep aborted before execution.");
80
+ }
81
+
82
+ const resolvedPathFormat = pathFormat ?? DEFAULT_PATH_FORMAT;
83
+ const resolvedContext = context ?? 0;
84
+ const resolvedOutputMode = outputMode ?? DEFAULT_OUTPUT_MODE;
85
+ const limit = Math.min(
86
+ headLimit ?? GREP_HEAD_LIMIT_MAX,
87
+ GREP_HEAD_LIMIT_MAX
88
+ );
89
+ const matcher = buildMatcher({
90
+ pattern,
91
+ caseInsensitive: caseInsensitive ?? false,
92
+ matchAcrossLines: matchAcrossLines ?? false,
93
+ });
94
+ const absolutePath = Paths.resolve(path ?? ".", ctx.cwd);
95
+ const matches = await findMatches(absolutePath, glob, matcher, {
96
+ exclude,
97
+ includeDotfiles: includeDotfiles ?? false,
98
+ includeIgnored: includeIgnored ?? false,
99
+ });
100
+ const outcome = renderMatches(matches, resolvedOutputMode, limit, {
101
+ cwd: ctx.cwd,
102
+ pathFormat: resolvedPathFormat,
103
+ context: resolvedContext,
104
+ });
105
+ const content: Array<{ type: "text"; text: string }> = [
106
+ { type: "text", text: outcome.body },
107
+ ];
108
+
109
+ if (outcome.truncated) {
110
+ content.push({
111
+ type: "text",
112
+ text: `[grep tool: showing ${outcome.visibleItems} of ${outcome.totalItems} ${outcome.itemNoun}; narrow the pattern, scope to a specific path, or use a glob filter to reduce results.]`,
113
+ });
114
+ }
115
+
116
+ return {
117
+ content,
118
+ details: {
119
+ absolutePath,
120
+ outputMode: resolvedOutputMode,
121
+ exclude,
122
+ matchAcrossLines: matchAcrossLines ?? false,
123
+ context: resolvedContext,
124
+ includeDotfiles: includeDotfiles ?? false,
125
+ includeIgnored: includeIgnored ?? false,
126
+ pathFormat: resolvedPathFormat,
127
+ fileCount: outcome.fileCount,
128
+ totalMatches: outcome.totalMatches,
129
+ totalItems: outcome.totalItems,
130
+ visibleItems: outcome.visibleItems,
131
+ truncated: outcome.truncated,
132
+ },
133
+ };
134
+ },
135
+ renderCall(args, theme, context) {
136
+ return renderTitle((args ?? {}) as Partial<GrepInput>, theme, context);
137
+ },
138
+ renderResult(result, options, theme, context) {
139
+ const state = context.state as GrepCallState;
140
+ const details = result.details as
141
+ | { readonly fileCount?: number }
142
+ | undefined;
143
+
144
+ if (details?.fileCount !== undefined) {
145
+ state.fileCount = details.fileCount;
146
+ renderTitle(context.args ?? {}, theme, context);
147
+ }
148
+
149
+ return Renderer.renderBorderedResult({
150
+ result,
151
+ options,
152
+ theme,
153
+ context,
154
+ previewLines: PREVIEW_LINES,
155
+ });
156
+ },
157
+ });
158
+ }
@@ -0,0 +1,269 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OutputBudget } from "../../shared/OutputBudget";
3
+ import type { GrepMatch } from "./grep";
4
+ import { formatTitle, renderMatches } from "./render";
5
+
6
+ const fixture: readonly GrepMatch[] = [
7
+ {
8
+ filePath: "/repo/older.ts",
9
+ mtime: 1_000,
10
+ fileLines: ["alpha"],
11
+ ranges: [{ startLineNumber: 1, endLineNumber: 1 }],
12
+ lines: [{ lineNumber: 1, text: "alpha" }],
13
+ },
14
+ {
15
+ filePath: "/repo/newer.ts",
16
+ mtime: 2_000,
17
+ fileLines: ["intro", "alpha", "middle", "tail", "alphabet"],
18
+ ranges: [
19
+ { startLineNumber: 2, endLineNumber: 2 },
20
+ { startLineNumber: 5, endLineNumber: 5 },
21
+ ],
22
+ lines: [
23
+ { lineNumber: 2, text: "alpha" },
24
+ { lineNumber: 5, text: "alphabet" },
25
+ ],
26
+ },
27
+ ];
28
+
29
+ const relativeOptions = {
30
+ cwd: "/repo",
31
+ pathFormat: "relative",
32
+ context: 0,
33
+ } as const;
34
+
35
+ const absoluteOptions = {
36
+ cwd: "/repo",
37
+ pathFormat: "absolute",
38
+ context: 0,
39
+ } as const;
40
+
41
+ describe("renderMatches", () => {
42
+ test("files_with_matches sorts by recency desc and renders relative paths", () => {
43
+ const outcome = renderMatches(
44
+ fixture,
45
+ "files_with_matches",
46
+ 1000,
47
+ relativeOptions
48
+ );
49
+ expect(outcome.body).toBe("newer.ts\nolder.ts");
50
+ expect(outcome.fileCount).toBe(2);
51
+ expect(outcome.totalMatches).toBe(3);
52
+ expect(outcome.totalItems).toBe(2);
53
+ expect(outcome.truncated).toBe(false);
54
+ });
55
+
56
+ test("can render absolute paths", () => {
57
+ const outcome = renderMatches(
58
+ fixture,
59
+ "files_with_matches",
60
+ 1000,
61
+ absoluteOptions
62
+ );
63
+ expect(outcome.body).toBe("/repo/newer.ts\n/repo/older.ts");
64
+ });
65
+
66
+ test("content emits path:line:text lines without markers when context is omitted", () => {
67
+ const outcome = renderMatches(fixture, "content", 1000, relativeOptions);
68
+ expect(outcome.body).toBe(
69
+ ["newer.ts:2:alpha", "newer.ts:5:alphabet", "older.ts:1:alpha"].join("\n")
70
+ );
71
+ expect(outcome.totalItems).toBe(3);
72
+ });
73
+
74
+ test("content can include context lines and distinguish matches", () => {
75
+ const outcome = renderMatches(fixture, "content", 1000, {
76
+ ...relativeOptions,
77
+ context: 1,
78
+ });
79
+ expect(outcome.body).toBe(
80
+ [
81
+ " newer.ts:1:intro",
82
+ "> newer.ts:2:alpha",
83
+ " newer.ts:3:middle",
84
+ " newer.ts:4:tail",
85
+ "> newer.ts:5:alphabet",
86
+ "> older.ts:1:alpha",
87
+ ].join("\n")
88
+ );
89
+ });
90
+
91
+ test("content inserts separators between non-overlapping context blocks", () => {
92
+ const outcome = renderMatches(
93
+ [
94
+ {
95
+ filePath: "/repo/gapped.ts",
96
+ mtime: 3_000,
97
+ fileLines: ["1", "2", "hit", "4", "5", "6", "hit", "8", "9"],
98
+ ranges: [
99
+ { startLineNumber: 3, endLineNumber: 3 },
100
+ { startLineNumber: 7, endLineNumber: 7 },
101
+ ],
102
+ lines: [
103
+ { lineNumber: 3, text: "hit" },
104
+ { lineNumber: 7, text: "hit" },
105
+ ],
106
+ },
107
+ ],
108
+ "content",
109
+ 1000,
110
+ { ...relativeOptions, context: 1 }
111
+ );
112
+
113
+ expect(outcome.body).toBe(
114
+ [
115
+ " gapped.ts:2:2",
116
+ "> gapped.ts:3:hit",
117
+ " gapped.ts:4:4",
118
+ "--",
119
+ " gapped.ts:6:6",
120
+ "> gapped.ts:7:hit",
121
+ " gapped.ts:8:8",
122
+ ].join("\n")
123
+ );
124
+ });
125
+
126
+ test("count sorts by match count desc, then mtime desc, then path asc", () => {
127
+ const outcome = renderMatches(fixture, "count", 1000, relativeOptions);
128
+ expect(outcome.body).toBe("newer.ts:2\nolder.ts:1");
129
+ });
130
+
131
+ test("flags truncation when results exceed headLimit", () => {
132
+ const outcome = renderMatches(fixture, "content", 2, relativeOptions);
133
+ expect(outcome.body).toBe("newer.ts:2:alpha\nnewer.ts:5:alphabet");
134
+ expect(outcome.truncated).toBe(true);
135
+ expect(outcome.visibleItems).toBe(2);
136
+ expect(outcome.totalItems).toBe(3);
137
+ });
138
+
139
+ test("returns a no-match outcome when there are no results", () => {
140
+ const outcome = renderMatches([], "content", 1000, relativeOptions);
141
+ expect(outcome.body).toBe("No matches.");
142
+ expect(outcome.truncated).toBe(false);
143
+ expect(outcome.fileCount).toBe(0);
144
+ expect(outcome.totalMatches).toBe(0);
145
+ });
146
+
147
+ test("truncates matched lines from minified files so a single hit cannot blow up the body", () => {
148
+ const minified = "a".repeat(50_000);
149
+ const outcome = renderMatches(
150
+ [
151
+ {
152
+ filePath: "/repo/dist/bundle.js",
153
+ mtime: 1_000,
154
+ fileLines: [minified],
155
+ ranges: [{ startLineNumber: 1, endLineNumber: 1 }],
156
+ lines: [{ lineNumber: 1, text: minified }],
157
+ },
158
+ ],
159
+ "content",
160
+ 1000,
161
+ relativeOptions
162
+ );
163
+
164
+ expect(outcome.body).toContain(
165
+ `(line truncated to ${OutputBudget.maxLineLength} chars)`
166
+ );
167
+ expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
168
+ OutputBudget.maxBytes
169
+ );
170
+ });
171
+
172
+ test("byte cap drops trailing rendered lines when many matches each push toward the cap", () => {
173
+ const longText = "x".repeat(OutputBudget.maxLineLength);
174
+ const fileLines = Array.from({ length: 40 }, () => longText);
175
+ const ranges = fileLines.map((_, index) => ({
176
+ startLineNumber: index + 1,
177
+ endLineNumber: index + 1,
178
+ }));
179
+ const lines = fileLines.map((text, index) => ({
180
+ lineNumber: index + 1,
181
+ text,
182
+ }));
183
+
184
+ const outcome = renderMatches(
185
+ [
186
+ {
187
+ filePath: "/repo/big.txt",
188
+ mtime: 1_000,
189
+ fileLines,
190
+ ranges,
191
+ lines,
192
+ },
193
+ ],
194
+ "content",
195
+ 1000,
196
+ relativeOptions
197
+ );
198
+
199
+ expect(outcome.truncated).toBe(true);
200
+ expect(outcome.visibleItems).toBeLessThan(outcome.totalItems);
201
+ expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
202
+ OutputBudget.maxBytes
203
+ );
204
+ });
205
+ });
206
+
207
+ describe("formatTitle", () => {
208
+ test("uses relative path under cwd and includes glob", () => {
209
+ const title = formatTitle({
210
+ pattern: "alpha",
211
+ path: "/repo/src",
212
+ glob: "**/*.ts",
213
+ cwd: "/repo",
214
+ });
215
+ expect(title).toBe("/alpha/ in src **/*.ts");
216
+ });
217
+
218
+ test("formats regex patterns with slashes", () => {
219
+ const title = formatTitle({
220
+ pattern: "^alpha$",
221
+ path: "/repo/src",
222
+ glob: undefined,
223
+ cwd: "/repo",
224
+ });
225
+ expect(title).toBe("/^alpha$/ in src");
226
+ });
227
+
228
+ test("falls back to '.' when path is omitted", () => {
229
+ const title = formatTitle({
230
+ pattern: "alpha",
231
+ path: undefined,
232
+ glob: undefined,
233
+ cwd: "/repo",
234
+ });
235
+ expect(title).toBe("/alpha/ in .");
236
+ });
237
+
238
+ test("resolves relative paths in titles", () => {
239
+ const title = formatTitle({
240
+ pattern: "alpha",
241
+ path: "src",
242
+ glob: undefined,
243
+ cwd: "/repo",
244
+ });
245
+ expect(title).toBe("/alpha/ in src");
246
+ });
247
+
248
+ test("appends pluralized file count when provided", () => {
249
+ const title = formatTitle({
250
+ pattern: "alpha",
251
+ path: "/repo/src",
252
+ glob: "**/*.ts",
253
+ cwd: "/repo",
254
+ fileCount: 3,
255
+ });
256
+ expect(title).toBe("/alpha/ in src **/*.ts (3 files)");
257
+ });
258
+
259
+ test("uses singular noun for a single file", () => {
260
+ const title = formatTitle({
261
+ pattern: "alpha",
262
+ path: undefined,
263
+ glob: undefined,
264
+ cwd: "/repo",
265
+ fileCount: 1,
266
+ });
267
+ expect(title).toBe("/alpha/ in . (1 file)");
268
+ });
269
+ });