@aaroncql/pim-agent 0.0.1 → 0.1.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.
Files changed (60) hide show
  1. package/README.md +19 -8
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/bash/capture.test.ts +0 -126
  6. package/src/extensions/bash/format.test.ts +0 -240
  7. package/src/extensions/bash/run.test.ts +0 -262
  8. package/src/extensions/command-picker/ranker.test.ts +0 -46
  9. package/src/extensions/edit/edit.test.ts +0 -285
  10. package/src/extensions/file-picker/catalog.test.ts +0 -263
  11. package/src/extensions/file-picker/index.test.ts +0 -168
  12. package/src/extensions/file-picker/ranker.test.ts +0 -94
  13. package/src/extensions/footer/git.test.ts +0 -76
  14. package/src/extensions/footer/index.test.ts +0 -161
  15. package/src/extensions/footer/segments.test.ts +0 -164
  16. package/src/extensions/glob/glob.test.ts +0 -171
  17. package/src/extensions/glob/index.test.ts +0 -68
  18. package/src/extensions/glob/render.test.ts +0 -126
  19. package/src/extensions/grep/grep.test.ts +0 -387
  20. package/src/extensions/grep/index.test.ts +0 -68
  21. package/src/extensions/grep/render.test.ts +0 -269
  22. package/src/extensions/read/read.test.ts +0 -177
  23. package/src/extensions/read/render.test.ts +0 -61
  24. package/src/extensions/subagent/index.test.ts +0 -44
  25. package/src/extensions/subagent/render.test.ts +0 -292
  26. package/src/extensions/subagent/subagent.test.ts +0 -315
  27. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  28. package/src/extensions/todo/index.test.ts +0 -244
  29. package/src/extensions/todo/render.test.ts +0 -180
  30. package/src/extensions/todo/todo.test.ts +0 -222
  31. package/src/extensions/tps/index.test.ts +0 -254
  32. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  33. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  34. package/src/extensions/web-fetch/render.test.ts +0 -56
  35. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  36. package/src/extensions/web-search/render.test.ts +0 -21
  37. package/src/extensions/web-search/search.test.ts +0 -53
  38. package/src/extensions/working-indicator/index.test.ts +0 -21
  39. package/src/extensions/write/render.test.ts +0 -64
  40. package/src/extensions/write/write.test.ts +0 -108
  41. package/src/shared/DiffLines.test.ts +0 -193
  42. package/src/shared/DiffRenderer.test.ts +0 -206
  43. package/src/shared/EditMatcher.test.ts +0 -123
  44. package/src/shared/FileScanner.test.ts +0 -158
  45. package/src/shared/FuzzyMatcher.test.ts +0 -114
  46. package/src/shared/GitignoreFilter.test.ts +0 -64
  47. package/src/shared/Lines.test.ts +0 -25
  48. package/src/shared/McpClient.test.ts +0 -235
  49. package/src/shared/OutputBudget.test.ts +0 -99
  50. package/src/shared/Paths.test.ts +0 -51
  51. package/src/shared/PimSettings.test.ts +0 -90
  52. package/src/shared/Renderer.test.ts +0 -190
  53. package/src/shared/SpillCache.test.ts +0 -94
  54. package/src/shared/Tools.test.ts +0 -392
  55. package/src/telegram/Config.test.ts +0 -275
  56. package/src/telegram/Markdown.test.ts +0 -143
  57. package/src/telegram/Renderer.test.ts +0 -216
  58. package/src/telegram/SessionRegistry.test.ts +0 -89
  59. package/src/telegram/TaskScheduler.test.ts +0 -278
  60. package/src/telegram/TaskTool.test.ts +0 -179
@@ -1,235 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { McpClient } from "./McpClient";
3
-
4
- type MockFetch = (
5
- input: Parameters<typeof fetch>[0],
6
- init?: Parameters<typeof fetch>[1]
7
- ) => ReturnType<typeof fetch>;
8
-
9
- type CapturedRequest = {
10
- readonly url: string;
11
- readonly headers: Headers;
12
- readonly body: Readonly<Record<string, unknown>>;
13
- };
14
-
15
- const captureRequest = async (
16
- input: Parameters<typeof fetch>[0],
17
- init: Parameters<typeof fetch>[1] | undefined
18
- ): Promise<CapturedRequest> => {
19
- if (input instanceof Request) {
20
- const text = await input.clone().text();
21
-
22
- return {
23
- url: input.url,
24
- headers: input.headers,
25
- body: JSON.parse(text) as Readonly<Record<string, unknown>>,
26
- };
27
- }
28
-
29
- return {
30
- url: String(input),
31
- headers: new Headers(init?.headers),
32
- body: JSON.parse(String(init?.body)) as Readonly<Record<string, unknown>>,
33
- };
34
- };
35
-
36
- const okToolCallResponse = (result: unknown): Response =>
37
- Response.json({ jsonrpc: "2.0", id: 2, result });
38
-
39
- test("performs the initialize → initialized → tools/call round trip", async () => {
40
- const requests: CapturedRequest[] = [];
41
- const fetcher: MockFetch = async (input, init) => {
42
- const request = await captureRequest(input, init);
43
- requests.push(request);
44
-
45
- if (request.body["method"] === "initialize") {
46
- return Response.json(
47
- { jsonrpc: "2.0", id: 1, result: {} },
48
- { headers: { "mcp-session-id": "session-json" } }
49
- );
50
- }
51
-
52
- if (request.body["method"] === "notifications/initialized") {
53
- return new Response(null, { status: 202 });
54
- }
55
-
56
- return okToolCallResponse({ ok: true });
57
- };
58
- const client = new McpClient({
59
- endpoint: "https://mcp.test/mcp",
60
- headers: { "x-api-key": "test-key" },
61
- fetch: fetcher,
62
- });
63
-
64
- await expect(
65
- client.callTool({ name: "demo_tool", arguments: { x: 1 } })
66
- ).resolves.toEqual({ ok: true });
67
-
68
- expect(requests.map((request) => request.url)).toEqual([
69
- "https://mcp.test/mcp",
70
- "https://mcp.test/mcp",
71
- "https://mcp.test/mcp",
72
- ]);
73
- expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-json");
74
- expect(requests[2]?.headers.get("mcp-session-id")).toBe("session-json");
75
- expect(requests[0]?.headers.get("x-api-key")).toBe("test-key");
76
- for (const request of requests) {
77
- expect(request.headers.get("mcp-protocol-version")).toBe("2025-06-18");
78
- }
79
- expect(requests[0]?.body).toEqual({
80
- jsonrpc: "2.0",
81
- id: 1,
82
- method: "initialize",
83
- params: {
84
- protocolVersion: "2025-06-18",
85
- clientInfo: {
86
- name: "pim-agent",
87
- version: "0.0.0",
88
- },
89
- capabilities: {},
90
- },
91
- });
92
- expect(requests[2]?.body).toEqual({
93
- jsonrpc: "2.0",
94
- id: 2,
95
- method: "tools/call",
96
- params: {
97
- name: "demo_tool",
98
- arguments: { x: 1 },
99
- },
100
- });
101
- });
102
-
103
- test("parses SSE responses", async () => {
104
- const ssePayload = [
105
- "event: message",
106
- `data: ${JSON.stringify({
107
- jsonrpc: "2.0",
108
- id: 2,
109
- result: { ok: true },
110
- })}`,
111
- "",
112
- "",
113
- ].join("\n");
114
- const fetcher: MockFetch = async (input, init) => {
115
- const request = await captureRequest(input, init);
116
-
117
- if (request.body["method"] === "initialize") {
118
- return Response.json(
119
- { jsonrpc: "2.0", id: 1, result: {} },
120
- { headers: { "mcp-session-id": "session-sse" } }
121
- );
122
- }
123
-
124
- if (request.body["method"] === "notifications/initialized") {
125
- return new Response(null, { status: 202 });
126
- }
127
-
128
- return new Response(ssePayload, {
129
- headers: { "content-type": "text/event-stream" },
130
- });
131
- };
132
- const client = new McpClient({
133
- endpoint: "https://mcp.test/mcp",
134
- fetch: fetcher,
135
- });
136
-
137
- await expect(
138
- client.callTool({ name: "demo_tool", arguments: {} })
139
- ).resolves.toEqual({ ok: true });
140
- });
141
-
142
- test("throws clean errors for HTTP failures", async () => {
143
- const fetcher: MockFetch = async () =>
144
- new Response("upstream unavailable and not useful beyond this excerpt", {
145
- status: 503,
146
- });
147
- const client = new McpClient({
148
- endpoint: "https://mcp.test/mcp",
149
- fetch: fetcher,
150
- });
151
-
152
- await expect(
153
- client.callTool({ name: "demo_tool", arguments: {} })
154
- ).rejects.toThrow("MCP request failed with HTTP 503: upstream unavailable");
155
- });
156
-
157
- test("throws clean errors for JSON-RPC failures", async () => {
158
- const fetcher: MockFetch = async (input, init) => {
159
- const request = await captureRequest(input, init);
160
-
161
- if (request.body["method"] === "initialize") {
162
- return Response.json(
163
- { jsonrpc: "2.0", id: 1, result: {} },
164
- { headers: { "mcp-session-id": "session-error" } }
165
- );
166
- }
167
-
168
- if (request.body["method"] === "notifications/initialized") {
169
- return new Response(null, { status: 202 });
170
- }
171
-
172
- return Response.json({
173
- jsonrpc: "2.0",
174
- id: 2,
175
- error: { code: -32602, message: "Invalid input." },
176
- });
177
- };
178
- const client = new McpClient({
179
- endpoint: "https://mcp.test/mcp",
180
- fetch: fetcher,
181
- });
182
-
183
- await expect(
184
- client.callTool({ name: "demo_tool", arguments: {} })
185
- ).rejects.toThrow("MCP JSON-RPC error: Invalid input.");
186
- });
187
-
188
- test("sends cancellation notification on aborted calls", async () => {
189
- const abortController = new AbortController();
190
- const requests: CapturedRequest[] = [];
191
- const fetcher: MockFetch = async (input, init) => {
192
- const request = await captureRequest(input, init);
193
- requests.push(request);
194
-
195
- if (request.body["method"] === "initialize") {
196
- return Response.json(
197
- { jsonrpc: "2.0", id: 1, result: {} },
198
- { headers: { "mcp-session-id": "session-abort" } }
199
- );
200
- }
201
-
202
- if (request.body["method"] === "notifications/initialized") {
203
- return new Response(null, { status: 202 });
204
- }
205
-
206
- if (request.body["method"] === "tools/call") {
207
- abortController.abort();
208
- throw new DOMException("Aborted", "AbortError");
209
- }
210
-
211
- return new Response(null, { status: 202 });
212
- };
213
- const client = new McpClient({
214
- endpoint: "https://mcp.test/mcp",
215
- fetch: fetcher,
216
- });
217
-
218
- await expect(
219
- client.callTool({
220
- name: "demo_tool",
221
- arguments: {},
222
- signal: abortController.signal,
223
- })
224
- ).rejects.toThrow("MCP request aborted.");
225
-
226
- expect(requests.at(-1)?.body).toEqual({
227
- jsonrpc: "2.0",
228
- method: "notifications/cancelled",
229
- params: {
230
- requestId: 2,
231
- reason: "Tool call aborted.",
232
- },
233
- });
234
- expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-abort");
235
- });
@@ -1,99 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { OutputBudget } from "./OutputBudget";
3
-
4
- describe("truncateLine", () => {
5
- test("returns lines under the cap untouched", () => {
6
- expect(OutputBudget.truncateLine("short")).toBe("short");
7
- });
8
-
9
- test("truncates with a signposted suffix once over the cap", () => {
10
- const line = "x".repeat(OutputBudget.maxLineLength + 50);
11
- expect(OutputBudget.truncateLine(line)).toBe(
12
- `${"x".repeat(OutputBudget.maxLineLength)}... (line truncated to ${OutputBudget.maxLineLength} chars)`
13
- );
14
- });
15
- });
16
-
17
- describe("truncateUtf8", () => {
18
- test("returns content unchanged when under cap", () => {
19
- const result = OutputBudget.truncateUtf8("hello", 1024);
20
- expect(result.body).toBe("hello");
21
- expect(result.truncated).toBe(false);
22
- expect(result.totalBytes).toBe(5);
23
- expect(result.returnedBytes).toBe(5);
24
- });
25
-
26
- test("truncates ASCII at exact byte boundary", () => {
27
- const result = OutputBudget.truncateUtf8("a".repeat(100), 10);
28
- expect(result.body).toBe("a".repeat(10));
29
- expect(result.truncated).toBe(true);
30
- expect(result.returnedBytes).toBe(10);
31
- expect(result.totalBytes).toBe(100);
32
- });
33
-
34
- test("backs off to a UTF-8 boundary mid-codepoint", () => {
35
- const result = OutputBudget.truncateUtf8("é", 1);
36
- expect(result.body).toBe("");
37
- expect(result.returnedBytes).toBe(0);
38
- expect(result.truncated).toBe(true);
39
- expect(result.totalBytes).toBe(2);
40
- });
41
-
42
- test("preserves complete multi-byte chars", () => {
43
- const result = OutputBudget.truncateUtf8("héllo", 3);
44
- expect(result.body).toBe("hé");
45
- expect(result.returnedBytes).toBe(3);
46
- expect(result.truncated).toBe(true);
47
- expect(result.totalBytes).toBe(6);
48
- });
49
-
50
- test("backs off across a 4-byte sequence", () => {
51
- const result = OutputBudget.truncateUtf8("🦀x", 2);
52
- expect(result.body).toBe("");
53
- expect(result.returnedBytes).toBe(0);
54
- expect(result.truncated).toBe(true);
55
- });
56
-
57
- test("defaults to OutputBudget.maxBytes when no cap supplied", () => {
58
- const content = "a".repeat(OutputBudget.maxBytes + 100);
59
- const result = OutputBudget.truncateUtf8(content);
60
- expect(result.body.length).toBe(OutputBudget.maxBytes);
61
- expect(result.truncated).toBe(true);
62
- });
63
- });
64
-
65
- describe("applyByteCap", () => {
66
- test("returns all items when their joined length fits", () => {
67
- const items = ["alpha", "beta", "gamma"];
68
- const result = OutputBudget.applyByteCap(items);
69
- expect(result.visible).toEqual(items);
70
- expect(result.droppedItems).toBe(0);
71
- });
72
-
73
- test("drops trailing items past the byte cap", () => {
74
- const item = "x".repeat(100);
75
- const items = Array.from({ length: 20 }, () => item);
76
- const result = OutputBudget.applyByteCap(items, { maxBytes: 250 });
77
- // 100 + 1 + 100 = 201 fits, adding another 1 + 100 = 302 does not.
78
- expect(result.visible).toHaveLength(2);
79
- expect(result.droppedItems).toBe(18);
80
- });
81
-
82
- test("always includes the first item even when it overflows alone", () => {
83
- const head = "x".repeat(500);
84
- const result = OutputBudget.applyByteCap([head, "tail"], {
85
- maxBytes: 100,
86
- });
87
- expect(result.visible).toEqual([head]);
88
- expect(result.droppedItems).toBe(1);
89
- });
90
-
91
- test("respects a custom separator", () => {
92
- // Two 5-byte items with a 10-byte separator: 5 + 10 + 5 = 20.
93
- const result = OutputBudget.applyByteCap(["alpha", "betas"], {
94
- maxBytes: 20,
95
- separator: "----------",
96
- });
97
- expect(result.visible).toEqual(["alpha", "betas"]);
98
- });
99
- });
@@ -1,51 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import { describe, expect, test } from "bun:test";
4
- import { Paths } from "./Paths";
5
-
6
- describe("Paths.resolve", () => {
7
- test("resolves relative paths against base", () => {
8
- expect(Paths.resolve("foo.txt", "/work")).toBe("/work/foo.txt");
9
- expect(Paths.resolve("src/foo.ts", "/work")).toBe("/work/src/foo.ts");
10
- });
11
-
12
- test("expands ~ to home directory", () => {
13
- const home = homedir();
14
- expect(Paths.resolve("~", "/tmp")).toBe(home);
15
- expect(Paths.resolve("~/.config", "/tmp")).toBe(join(home, ".config"));
16
- });
17
-
18
- test("preserves absolute paths", () => {
19
- expect(Paths.resolve("/etc/hosts", "/tmp")).toBe("/etc/hosts");
20
- });
21
- });
22
-
23
- describe("Paths.displayRelative", () => {
24
- test("returns relative path when inside cwd", () => {
25
- expect(Paths.displayRelative("/work/src/foo.ts", "/work")).toBe(
26
- "src/foo.ts"
27
- );
28
- });
29
-
30
- test("returns absolute path when outside cwd", () => {
31
- expect(Paths.displayRelative("/etc/hosts", "/work")).toBe("/etc/hosts");
32
- });
33
-
34
- test("returns absolute path when cwd equals path", () => {
35
- expect(Paths.displayRelative("/work", "/work")).toBe("/work");
36
- });
37
- });
38
-
39
- describe("Paths.titleOr", () => {
40
- test("returns the placeholder when path is undefined", () => {
41
- expect(Paths.titleOr(undefined, "/work")).toBe("...");
42
- });
43
-
44
- test("returns a path relative to cwd when within cwd", () => {
45
- expect(Paths.titleOr("/work/src/file.ts", "/work")).toBe("src/file.ts");
46
- });
47
-
48
- test("returns the absolute path when outside cwd", () => {
49
- expect(Paths.titleOr("/other/file.ts", "/work")).toBe("/other/file.ts");
50
- });
51
- });
@@ -1,90 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, stat } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { PimSettings } from "./PimSettings";
6
-
7
- let previousExaApiKey: string | undefined;
8
- let previousJinaApiKey: string | undefined;
9
- let previousPimHomeDir: string | undefined;
10
- let testPimHomeDir: string | undefined;
11
-
12
- beforeAll(async () => {
13
- previousExaApiKey = process.env.EXA_API_KEY;
14
- previousJinaApiKey = process.env.JINA_API_KEY;
15
- previousPimHomeDir = process.env.PIM_HOME_DIR;
16
- testPimHomeDir = await mkdtemp(join(tmpdir(), "pim-settings-home-"));
17
- delete process.env.EXA_API_KEY;
18
- delete process.env.JINA_API_KEY;
19
- process.env.PIM_HOME_DIR = testPimHomeDir;
20
- });
21
-
22
- afterAll(async () => {
23
- if (previousExaApiKey === undefined) {
24
- delete process.env.EXA_API_KEY;
25
- } else {
26
- process.env.EXA_API_KEY = previousExaApiKey;
27
- }
28
- if (previousJinaApiKey === undefined) {
29
- delete process.env.JINA_API_KEY;
30
- } else {
31
- process.env.JINA_API_KEY = previousJinaApiKey;
32
- }
33
- if (previousPimHomeDir === undefined) {
34
- delete process.env.PIM_HOME_DIR;
35
- } else {
36
- process.env.PIM_HOME_DIR = previousPimHomeDir;
37
- }
38
- if (testPimHomeDir) {
39
- await rm(testPimHomeDir, { recursive: true, force: true });
40
- }
41
- });
42
-
43
- describe("PimSettings", () => {
44
- test("loads defaults from ~/.pim/settings.json", async () => {
45
- expect(PimSettings.path()).toBe(join(testPimHomeDir!, "settings.json"));
46
- await expect(PimSettings.get("tps")).resolves.toEqual({ enabled: false });
47
- await expect(PimSettings.get("powerline")).resolves.toEqual({
48
- enabled: true,
49
- });
50
- await expect(PimSettings.get("exa")).resolves.toEqual({});
51
- await expect(PimSettings.get("jina")).resolves.toEqual({});
52
- });
53
-
54
- test("writes settings with private directory and file modes", async () => {
55
- await PimSettings.set("exa", { apiKey: "exa-test" });
56
- await PimSettings.set("jina", { apiKey: "jina-test" });
57
-
58
- const path = PimSettings.path();
59
- expect(path).toBe(join(testPimHomeDir!, "settings.json"));
60
- expect(await Bun.file(path).json()).toEqual({
61
- tps: { enabled: false },
62
- powerline: { enabled: true },
63
- exa: { apiKey: "exa-test" },
64
- jina: { apiKey: "jina-test" },
65
- });
66
-
67
- expect((await stat(testPimHomeDir!)).mode & 0o777).toBe(0o700);
68
- expect((await stat(path)).mode & 0o777).toBe(0o600);
69
- });
70
-
71
- test("resolves API keys from env vars before settings", async () => {
72
- await PimSettings.set("exa", { apiKey: "exa-test" });
73
- await PimSettings.set("jina", { apiKey: "jina-test" });
74
-
75
- await expect(PimSettings.getExaApiKey()).resolves.toBe("exa-test");
76
- await expect(PimSettings.getJinaApiKey()).resolves.toBe("jina-test");
77
-
78
- process.env.EXA_API_KEY = " exa-env ";
79
- process.env.JINA_API_KEY = "";
80
-
81
- await expect(PimSettings.getExaApiKey()).resolves.toBe("exa-env");
82
- await expect(PimSettings.getJinaApiKey()).resolves.toBe("jina-test");
83
- });
84
-
85
- test("rejects invalid root setting values", async () => {
86
- await expect(
87
- PimSettings.set("exa", { apiKey: 123 } as never)
88
- ).rejects.toThrow('Invalid value for pim setting "exa"');
89
- });
90
- });
@@ -1,190 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- AgentToolResult,
4
- Theme,
5
- ThemeColor,
6
- ToolRenderResultOptions,
7
- } from "@earendil-works/pi-coding-agent";
8
- import { visibleWidth } from "@earendil-works/pi-tui";
9
- import { Renderer } from "./Renderer";
10
-
11
- const stubTheme = {
12
- bold: (text: string) => text,
13
- fg: (_color: string, text: string) => text,
14
- } as unknown as Theme;
15
-
16
- function tracingTheme(): {
17
- readonly theme: Theme;
18
- readonly calls: { readonly color: ThemeColor; readonly text: string }[];
19
- } {
20
- const calls: { color: ThemeColor; text: string }[] = [];
21
- return {
22
- calls,
23
- theme: {
24
- bold: (text: string) => text,
25
- fg: (color: ThemeColor, text: string) => {
26
- calls.push({ color, text });
27
- return text;
28
- },
29
- } as unknown as Theme,
30
- };
31
- }
32
-
33
- const expandedOptions = {
34
- expanded: true,
35
- isPartial: false,
36
- } satisfies ToolRenderResultOptions;
37
-
38
- const rendererContext = {
39
- lastComponent: undefined,
40
- isPartial: false,
41
- isError: false,
42
- } as const;
43
-
44
- function textResult(text: string): AgentToolResult<unknown> {
45
- return { content: [{ type: "text", text }], details: undefined };
46
- }
47
-
48
- describe("Renderer.markerColorFor", () => {
49
- test("partial wins over error", () => {
50
- expect(Renderer.markerColorFor(true, true)).toBe("warning");
51
- });
52
- test("error when not partial", () => {
53
- expect(Renderer.markerColorFor(false, true)).toBe("error");
54
- });
55
- test("success otherwise", () => {
56
- expect(Renderer.markerColorFor(false, false)).toBe("success");
57
- });
58
- });
59
-
60
- describe("Renderer.buildPreviewLines", () => {
61
- test("returns body unchanged when within limit", () => {
62
- expect(Renderer.buildPreviewLines("a\nb\nc", 5)).toEqual({
63
- preview: "a\nb\nc",
64
- overflow: 0,
65
- });
66
- });
67
- test("truncates and reports overflow", () => {
68
- const body = "1\n2\n3\n4\n5\n6\n7";
69
- expect(Renderer.buildPreviewLines(body, 3)).toEqual({
70
- preview: "1\n2\n3",
71
- overflow: 4,
72
- });
73
- });
74
- test("limit equal to line count is not truncated", () => {
75
- expect(Renderer.buildPreviewLines("a\nb\nc", 3)).toEqual({
76
- preview: "a\nb\nc",
77
- overflow: 0,
78
- });
79
- });
80
- });
81
-
82
- describe("Renderer.renderBorderedResult", () => {
83
- test("wraps expanded output by default", () => {
84
- const component = Renderer.renderBorderedResult({
85
- result: textResult("0123456789abcdef\nnext"),
86
- options: expandedOptions,
87
- theme: stubTheme,
88
- context: rendererContext,
89
- previewLines: 10,
90
- });
91
-
92
- expect(component.render(10)).toHaveLength(4);
93
- });
94
-
95
- test("expanded output includes all lines even beyond the preview limit", () => {
96
- const component = Renderer.renderBorderedResult({
97
- result: textResult(
98
- [
99
- " src/file.ts:10:before",
100
- "> src/file.ts:11:matched",
101
- " src/file.ts:12:after",
102
- ].join("\n")
103
- ),
104
- options: expandedOptions,
105
- theme: stubTheme,
106
- context: rendererContext,
107
- previewLines: 1,
108
- });
109
-
110
- expect(component.render(80)).toEqual([
111
- " │ src/file.ts:10:before",
112
- " │ > src/file.ts:11:matched",
113
- " │ src/file.ts:12:after",
114
- ]);
115
- });
116
- });
117
-
118
- describe("Renderer.renderToolCallTitle", () => {
119
- test("leaves single-line titles unbordered", () => {
120
- const component = Renderer.renderToolCallTitle({
121
- label: "Bash",
122
- title: "pwd",
123
- theme: stubTheme,
124
- context: {
125
- lastComponent: undefined,
126
- isPartial: false,
127
- isError: false,
128
- },
129
- });
130
-
131
- expect(component.render(80)).toEqual([" ▪ Bash: pwd".padEnd(80, " ")]);
132
- });
133
-
134
- test("labelColor overrides the default label color when provided", () => {
135
- const overridden = tracingTheme();
136
- Renderer.renderToolCallTitle({
137
- label: "Subagent",
138
- title: "investigate",
139
- theme: overridden.theme,
140
- context: {
141
- lastComponent: undefined,
142
- isPartial: false,
143
- isError: false,
144
- },
145
- labelColor: "accent",
146
- }).render(80);
147
-
148
- expect(overridden.calls).toContainEqual({
149
- color: "accent",
150
- text: "Subagent",
151
- });
152
- expect(overridden.calls).not.toContainEqual({
153
- color: "toolTitle",
154
- text: "Subagent",
155
- });
156
-
157
- const fallback = tracingTheme();
158
- Renderer.renderToolCallTitle({
159
- label: "Bash",
160
- title: "pwd",
161
- theme: fallback.theme,
162
- context: {
163
- lastComponent: undefined,
164
- isPartial: false,
165
- isError: false,
166
- },
167
- }).render(80);
168
-
169
- expect(fallback.calls).toContainEqual({ color: "toolTitle", text: "Bash" });
170
- });
171
-
172
- test("adds a left border to wrapped title lines", () => {
173
- const width = 18;
174
- const component = Renderer.renderToolCallTitle({
175
- label: "Bash",
176
- title: "one two three four five six seven",
177
- theme: stubTheme,
178
- context: {
179
- lastComponent: undefined,
180
- isPartial: false,
181
- isError: false,
182
- },
183
- });
184
- const lines = component.render(width);
185
-
186
- expect(lines.length).toBeGreaterThan(1);
187
- expect(lines.slice(1).every((line) => line.startsWith(" │ "))).toBe(true);
188
- expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true);
189
- });
190
- });