@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type Static, Type } from "typebox";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
5
|
+
|
|
6
|
+
import { Fs } from "./Fs";
|
|
7
|
+
import { Paths } from "./Paths";
|
|
8
|
+
|
|
9
|
+
const Schema = Type.Object({
|
|
10
|
+
tps: Type.Object(
|
|
11
|
+
{
|
|
12
|
+
enabled: Type.Boolean({ default: false }),
|
|
13
|
+
},
|
|
14
|
+
{ default: { enabled: false } }
|
|
15
|
+
),
|
|
16
|
+
powerline: Type.Object(
|
|
17
|
+
{
|
|
18
|
+
enabled: Type.Boolean({ default: true }),
|
|
19
|
+
},
|
|
20
|
+
{ default: { enabled: true } }
|
|
21
|
+
),
|
|
22
|
+
exa: Type.Object(
|
|
23
|
+
{
|
|
24
|
+
apiKey: Type.Optional(Type.String()),
|
|
25
|
+
},
|
|
26
|
+
{ default: {} }
|
|
27
|
+
),
|
|
28
|
+
jina: Type.Object(
|
|
29
|
+
{
|
|
30
|
+
apiKey: Type.Optional(Type.String()),
|
|
31
|
+
},
|
|
32
|
+
{ default: {} }
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
type Settings = Static<typeof Schema>;
|
|
37
|
+
|
|
38
|
+
export class PimSettings {
|
|
39
|
+
private static cache: Settings | undefined;
|
|
40
|
+
private static cachePath: string | undefined;
|
|
41
|
+
private static loadPromise: Promise<Settings> | undefined;
|
|
42
|
+
private static loadPromisePath: string | undefined;
|
|
43
|
+
private static writeQueue: Promise<unknown> = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
public static path(): string {
|
|
46
|
+
return join(Paths.pimHomeDir(), "settings.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async load(): Promise<Settings> {
|
|
50
|
+
const path = PimSettings.path();
|
|
51
|
+
if (PimSettings.cache !== undefined && PimSettings.cachePath === path) {
|
|
52
|
+
return PimSettings.cache;
|
|
53
|
+
}
|
|
54
|
+
if (PimSettings.loadPromisePath !== path) {
|
|
55
|
+
PimSettings.loadPromise = undefined;
|
|
56
|
+
PimSettings.loadPromisePath = path;
|
|
57
|
+
}
|
|
58
|
+
PimSettings.loadPromise ??= (async () => {
|
|
59
|
+
let raw: unknown;
|
|
60
|
+
try {
|
|
61
|
+
raw = await Bun.file(path).json();
|
|
62
|
+
} catch {
|
|
63
|
+
raw = {};
|
|
64
|
+
}
|
|
65
|
+
const filled = Value.Default(Schema, raw);
|
|
66
|
+
const settings: Settings = Value.Check(Schema, filled)
|
|
67
|
+
? filled
|
|
68
|
+
: Value.Create(Schema);
|
|
69
|
+
PimSettings.cache = settings;
|
|
70
|
+
PimSettings.cachePath = path;
|
|
71
|
+
return settings;
|
|
72
|
+
})();
|
|
73
|
+
return PimSettings.loadPromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static async ensureHomeDir(): Promise<void> {
|
|
77
|
+
const dir = Paths.pimHomeDir();
|
|
78
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
79
|
+
await chmod(dir, 0o700);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public static async getExaApiKey(): Promise<string | undefined> {
|
|
83
|
+
return (
|
|
84
|
+
PimSettings.normalize(process.env["EXA_API_KEY"]) ??
|
|
85
|
+
PimSettings.normalize((await PimSettings.get("exa")).apiKey)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public static async getJinaApiKey(): Promise<string | undefined> {
|
|
90
|
+
return (
|
|
91
|
+
PimSettings.normalize(process.env["JINA_API_KEY"]) ??
|
|
92
|
+
PimSettings.normalize((await PimSettings.get("jina")).apiKey)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static normalize(value: string | undefined): string | undefined {
|
|
97
|
+
const trimmed = value?.trim();
|
|
98
|
+
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static async get<K extends keyof Settings>(key: K): Promise<Settings[K]> {
|
|
102
|
+
return (await PimSettings.load())[key];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static async set<K extends keyof Settings>(
|
|
106
|
+
key: K,
|
|
107
|
+
value: Settings[K]
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const task = async (): Promise<void> => {
|
|
110
|
+
const current = await PimSettings.load();
|
|
111
|
+
const next: Settings = { ...current, [key]: value };
|
|
112
|
+
if (!Value.Check(Schema, next)) {
|
|
113
|
+
throw new Error(`Invalid value for pim setting "${String(key)}"`);
|
|
114
|
+
}
|
|
115
|
+
const path = PimSettings.path();
|
|
116
|
+
PimSettings.cache = next;
|
|
117
|
+
PimSettings.cachePath = path;
|
|
118
|
+
await PimSettings.ensureHomeDir();
|
|
119
|
+
await Fs.writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`, 0o600);
|
|
120
|
+
};
|
|
121
|
+
PimSettings.writeQueue = PimSettings.writeQueue.then(task, task);
|
|
122
|
+
await PimSettings.writeQueue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
Theme,
|
|
4
|
+
ThemeColor,
|
|
5
|
+
ToolRenderResultOptions,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
Container,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
wrapTextWithAnsi,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export type RenderContext = {
|
|
15
|
+
readonly lastComponent: Component | undefined;
|
|
16
|
+
readonly isPartial: boolean;
|
|
17
|
+
readonly isError: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StatefulToolCallTitleContext = RenderContext & {
|
|
21
|
+
readonly state: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type StatefulToolCallTitleState = {
|
|
25
|
+
titleComponent?: Component;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MarkerStatus = "warning" | "error" | "success";
|
|
29
|
+
|
|
30
|
+
export type PrefixSpec = {
|
|
31
|
+
readonly prefix: string;
|
|
32
|
+
readonly width: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
class ToolTitle implements Component {
|
|
36
|
+
private text = "";
|
|
37
|
+
private theme: Theme | undefined;
|
|
38
|
+
|
|
39
|
+
public setText(text: string, theme: Theme): void {
|
|
40
|
+
this.text = text;
|
|
41
|
+
this.theme = theme;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public render(width: number): string[] {
|
|
45
|
+
if (!this.text || this.text.trim() === "") {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const theme = this.theme;
|
|
50
|
+
const normalized = this.text.replace(/\t/g, " ");
|
|
51
|
+
const lines = wrapTextWithAnsi(normalized, Math.max(1, width));
|
|
52
|
+
|
|
53
|
+
if (lines.length <= 1 || theme === undefined) {
|
|
54
|
+
return lines.map((line) => ToolTitle.padLine(line, width));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inner = Math.max(1, width - Renderer.GAPPED_PREFIX.width);
|
|
58
|
+
const out = [ToolTitle.padLine(lines[0] ?? "", width)];
|
|
59
|
+
|
|
60
|
+
for (const logical of lines.slice(1)) {
|
|
61
|
+
for (const wrapped of wrapTextWithAnsi(logical, inner)) {
|
|
62
|
+
out.push(
|
|
63
|
+
ToolTitle.padLine(
|
|
64
|
+
theme.fg("toolOutput", Renderer.GAPPED_PREFIX.prefix) + wrapped,
|
|
65
|
+
width
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public invalidate(): void {}
|
|
75
|
+
|
|
76
|
+
private static padLine(line: string, width: number): string {
|
|
77
|
+
return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class Renderer {
|
|
82
|
+
public static readonly GAPPED_PREFIX: PrefixSpec = {
|
|
83
|
+
prefix: " │ ",
|
|
84
|
+
width: 3,
|
|
85
|
+
};
|
|
86
|
+
public static readonly TIGHT_PREFIX: PrefixSpec = {
|
|
87
|
+
prefix: " │",
|
|
88
|
+
width: 2,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
public static markerColorFor(
|
|
92
|
+
isPartial: boolean,
|
|
93
|
+
isError: boolean
|
|
94
|
+
): MarkerStatus {
|
|
95
|
+
if (isPartial) {
|
|
96
|
+
return "warning";
|
|
97
|
+
}
|
|
98
|
+
if (isError) {
|
|
99
|
+
return "error";
|
|
100
|
+
}
|
|
101
|
+
return "success";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public static extractErrorText(
|
|
105
|
+
result: {
|
|
106
|
+
readonly content?: ReadonlyArray<{
|
|
107
|
+
readonly type: string;
|
|
108
|
+
readonly text?: string;
|
|
109
|
+
}>;
|
|
110
|
+
},
|
|
111
|
+
fallback: string
|
|
112
|
+
): string {
|
|
113
|
+
const text = (result.content ?? [])
|
|
114
|
+
.filter((item) => item.type === "text")
|
|
115
|
+
.map((item) => item.text ?? "")
|
|
116
|
+
.join("\n")
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
return text || fallback;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public static buildPreviewLines(
|
|
123
|
+
body: string,
|
|
124
|
+
maxLines: number
|
|
125
|
+
): { preview: string; overflow: number } {
|
|
126
|
+
const lines = body.split("\n");
|
|
127
|
+
if (lines.length <= maxLines) {
|
|
128
|
+
return { preview: body, overflow: 0 };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
preview: lines.slice(0, maxLines).join("\n"),
|
|
132
|
+
overflow: lines.length - maxLines,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public static renderToolCallTitle(args: {
|
|
137
|
+
readonly label: string;
|
|
138
|
+
readonly title: string;
|
|
139
|
+
readonly theme: Theme;
|
|
140
|
+
readonly context: RenderContext;
|
|
141
|
+
readonly labelColor?: ThemeColor;
|
|
142
|
+
}): Component {
|
|
143
|
+
const { label, title, theme, context, labelColor } = args;
|
|
144
|
+
const markerColor = Renderer.markerColorFor(
|
|
145
|
+
Boolean(context.isPartial),
|
|
146
|
+
Boolean(context.isError)
|
|
147
|
+
);
|
|
148
|
+
const component =
|
|
149
|
+
context.lastComponent instanceof ToolTitle
|
|
150
|
+
? context.lastComponent
|
|
151
|
+
: new ToolTitle();
|
|
152
|
+
component.setText(
|
|
153
|
+
theme.fg(markerColor, " ▪") +
|
|
154
|
+
" " +
|
|
155
|
+
theme.fg(labelColor ?? "toolTitle", theme.bold(label)) +
|
|
156
|
+
theme.fg("toolTitle", ": " + title),
|
|
157
|
+
theme
|
|
158
|
+
);
|
|
159
|
+
return component;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public static renderStatefulToolCallTitle(args: {
|
|
163
|
+
readonly label: string;
|
|
164
|
+
readonly title: string;
|
|
165
|
+
readonly theme: Theme;
|
|
166
|
+
readonly context: StatefulToolCallTitleContext;
|
|
167
|
+
readonly labelColor?: ThemeColor;
|
|
168
|
+
}): Component {
|
|
169
|
+
const state = args.context.state as StatefulToolCallTitleState;
|
|
170
|
+
const component = Renderer.renderToolCallTitle({
|
|
171
|
+
...args,
|
|
172
|
+
context: {
|
|
173
|
+
...args.context,
|
|
174
|
+
lastComponent: state.titleComponent ?? args.context.lastComponent,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
state.titleComponent = component;
|
|
178
|
+
return component;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public static makePrefixedBlock(args: {
|
|
182
|
+
readonly text: string;
|
|
183
|
+
readonly theme: Theme;
|
|
184
|
+
readonly prefix: PrefixSpec;
|
|
185
|
+
readonly lineColor?: ThemeColor;
|
|
186
|
+
}): Component {
|
|
187
|
+
const { text, theme, prefix, lineColor } = args;
|
|
188
|
+
return {
|
|
189
|
+
render(width: number): string[] {
|
|
190
|
+
const inner = Math.max(1, width - prefix.width);
|
|
191
|
+
const out: string[] = [];
|
|
192
|
+
for (const logical of text.split("\n")) {
|
|
193
|
+
for (const w of wrapTextWithAnsi(logical, inner)) {
|
|
194
|
+
const body = lineColor ? theme.fg(lineColor, w) : w;
|
|
195
|
+
out.push(theme.fg("toolOutput", prefix.prefix) + body);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
},
|
|
200
|
+
invalidate() {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
public static renderBorderedResult(args: {
|
|
205
|
+
readonly result: AgentToolResult<unknown>;
|
|
206
|
+
readonly options: ToolRenderResultOptions;
|
|
207
|
+
readonly theme: Theme;
|
|
208
|
+
readonly context: RenderContext;
|
|
209
|
+
readonly previewLines: number;
|
|
210
|
+
}): Container {
|
|
211
|
+
const { result, options, theme, context, previewLines } = args;
|
|
212
|
+
const container =
|
|
213
|
+
(context.lastComponent as Container | undefined) ?? new Container();
|
|
214
|
+
container.clear();
|
|
215
|
+
|
|
216
|
+
if (options.isPartial) {
|
|
217
|
+
return container;
|
|
218
|
+
}
|
|
219
|
+
if (!context.isError && !options.expanded) {
|
|
220
|
+
return container;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const first = result.content?.[0];
|
|
224
|
+
const body = first && "text" in first ? (first.text ?? "") : "";
|
|
225
|
+
if (!body) {
|
|
226
|
+
return container;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const lineColor = context.isError ? "error" : "toolOutput";
|
|
230
|
+
const block = (text: string): Component =>
|
|
231
|
+
Renderer.makePrefixedBlock({
|
|
232
|
+
text,
|
|
233
|
+
theme,
|
|
234
|
+
prefix: Renderer.GAPPED_PREFIX,
|
|
235
|
+
lineColor,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (options.expanded) {
|
|
239
|
+
container.addChild(block(body));
|
|
240
|
+
} else {
|
|
241
|
+
const { preview, overflow } = Renderer.buildPreviewLines(
|
|
242
|
+
body,
|
|
243
|
+
previewLines
|
|
244
|
+
);
|
|
245
|
+
if (preview) {
|
|
246
|
+
container.addChild(block(preview));
|
|
247
|
+
}
|
|
248
|
+
if (overflow > 0) {
|
|
249
|
+
container.addChild(block(`… ${overflow} more lines`));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
container.invalidate();
|
|
254
|
+
return container;
|
|
255
|
+
}
|
|
256
|
+
}
|