@pi-archimedes/core 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/package.json +2 -1
- package/src/config.test.ts +86 -0
- package/src/overlay.test.ts +181 -0
- package/src/overlay.ts +115 -0
- package/src/settings-io.test.ts +103 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/sections.test.ts +314 -0
- package/src/startup/version.test.ts +98 -0
- package/src/thinking/patch.test.ts +227 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/unindent.test.ts +74 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Symbols used by patch.ts to mark patched prototypes
|
|
4
|
+
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
5
|
+
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
6
|
+
|
|
7
|
+
// Dynamic import after mocking the pi-coding-agent module
|
|
8
|
+
async function importPatch() {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
const mod = await import("./patch.js");
|
|
11
|
+
return mod.patchThinkingRenderer;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("patchThinkingRenderer", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns early when AssistantMessageComponent is undefined", async () => {
|
|
20
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
21
|
+
AssistantMessageComponent: undefined,
|
|
22
|
+
VERSION: "1.0.0",
|
|
23
|
+
highlightCode: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const patch = await importPatch();
|
|
27
|
+
// Should not throw
|
|
28
|
+
patch(() => ({} as any));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns early when prototype is null", async () => {
|
|
32
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
33
|
+
MockClass.prototype = null;
|
|
34
|
+
|
|
35
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
36
|
+
AssistantMessageComponent: MockClass,
|
|
37
|
+
VERSION: "1.0.0",
|
|
38
|
+
highlightCode: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const patch = await importPatch();
|
|
42
|
+
patch(() => ({} as any));
|
|
43
|
+
// Prototype must still be null — no patching occurred
|
|
44
|
+
expect(MockClass.prototype).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns early when updateContent is not a function", async () => {
|
|
48
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
49
|
+
MockClass.prototype.updateContent = "not a function";
|
|
50
|
+
|
|
51
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
52
|
+
AssistantMessageComponent: MockClass,
|
|
53
|
+
VERSION: "1.0.0",
|
|
54
|
+
highlightCode: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const patch = await importPatch();
|
|
58
|
+
patch(() => ({} as any));
|
|
59
|
+
// PATCHED_KEY must NOT be set — early return before patching
|
|
60
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns early when class name does not match", async () => {
|
|
64
|
+
const WrongName = function () {};
|
|
65
|
+
WrongName.prototype.updateContent = function () {};
|
|
66
|
+
|
|
67
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
68
|
+
AssistantMessageComponent: WrongName,
|
|
69
|
+
VERSION: "1.0.0",
|
|
70
|
+
highlightCode: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const patch = await importPatch();
|
|
74
|
+
patch(() => ({} as any));
|
|
75
|
+
// PATCHED_KEY must NOT be set — name mismatch causes early return
|
|
76
|
+
expect(WrongName.prototype[PATCHED_KEY]).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns early when source lacks thinking check", async () => {
|
|
80
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
81
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
82
|
+
// Does NOT contain: content.type === "thinking"
|
|
83
|
+
this.doSomething();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
87
|
+
AssistantMessageComponent: MockClass,
|
|
88
|
+
VERSION: "1.0.0",
|
|
89
|
+
highlightCode: vi.fn(),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const patch = await importPatch();
|
|
93
|
+
patch(() => ({} as any));
|
|
94
|
+
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
95
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns early when source lacks markdownTheme reference", async () => {
|
|
99
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
100
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
101
|
+
// Has thinking check but no markdownTheme
|
|
102
|
+
if (this.content.type === "thinking") {
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
108
|
+
AssistantMessageComponent: MockClass,
|
|
109
|
+
VERSION: "1.0.0",
|
|
110
|
+
highlightCode: vi.fn(),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const patch = await importPatch();
|
|
114
|
+
patch(() => ({} as any));
|
|
115
|
+
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
116
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("patches successfully when signature matches", async () => {
|
|
120
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
121
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
122
|
+
if (this.content.type === "thinking") {
|
|
123
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
128
|
+
AssistantMessageComponent: MockClass,
|
|
129
|
+
VERSION: "1.0.0",
|
|
130
|
+
highlightCode: vi.fn(),
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const patch = await importPatch();
|
|
134
|
+
patch(() => ({} as any));
|
|
135
|
+
|
|
136
|
+
// The prototype should be marked as patched
|
|
137
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
138
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("marks prototype with correct version", async () => {
|
|
142
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
143
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
144
|
+
if (this.content.type === "thinking") {
|
|
145
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
150
|
+
AssistantMessageComponent: MockClass,
|
|
151
|
+
VERSION: "2.5.0",
|
|
152
|
+
highlightCode: vi.fn(),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const patch = await importPatch();
|
|
156
|
+
patch(() => ({} as any));
|
|
157
|
+
|
|
158
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("2.5.0");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("re-patches when version changes", async () => {
|
|
162
|
+
// First patch with version 1.0.0
|
|
163
|
+
const MockClassV1 = function AssistantMessageComponent() {};
|
|
164
|
+
MockClassV1.prototype.updateContent = function updateContent() {
|
|
165
|
+
if (this.content.type === "thinking") {
|
|
166
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
171
|
+
AssistantMessageComponent: MockClassV1,
|
|
172
|
+
VERSION: "1.0.0",
|
|
173
|
+
highlightCode: vi.fn(),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const patchV1 = await importPatch();
|
|
177
|
+
patchV1(() => ({} as any));
|
|
178
|
+
expect(MockClassV1.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
179
|
+
|
|
180
|
+
// Now simulate a version change with a fresh class
|
|
181
|
+
const MockClassV2 = function AssistantMessageComponent() {};
|
|
182
|
+
MockClassV2.prototype.updateContent = function updateContent() {
|
|
183
|
+
if (this.content.type === "thinking") {
|
|
184
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
189
|
+
AssistantMessageComponent: MockClassV2,
|
|
190
|
+
VERSION: "2.0.0",
|
|
191
|
+
highlightCode: vi.fn(),
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const patchV2 = await importPatch();
|
|
195
|
+
patchV2(() => ({} as any));
|
|
196
|
+
expect(MockClassV2.prototype[PATCH_VERSION_KEY]).toBe("2.0.0");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("re-patches on same version to update getTheme closure", async () => {
|
|
200
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
201
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
202
|
+
if (this.content.type === "thinking") {
|
|
203
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
208
|
+
AssistantMessageComponent: MockClass,
|
|
209
|
+
VERSION: "1.0.0",
|
|
210
|
+
highlightCode: vi.fn(),
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const patch = await importPatch();
|
|
214
|
+
|
|
215
|
+
// First patch
|
|
216
|
+
patch(() => ({} as any));
|
|
217
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
218
|
+
const first = MockClass.prototype.updateContent;
|
|
219
|
+
|
|
220
|
+
// Second patch (same version) — must produce a new function
|
|
221
|
+
patch(() => ({} as any));
|
|
222
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
223
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
224
|
+
// The patched function must be a new closure (not the same reference)
|
|
225
|
+
expect(MockClass.prototype.updateContent).not.toBe(first);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { dimAnsiLine, buildMutedMarkdownTheme } from "./theme.js";
|
|
4
|
+
import { stripAnsi } from "../text.js";
|
|
5
|
+
|
|
6
|
+
// ── dimAnsiLine ──────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("dimAnsiLine", () => {
|
|
9
|
+
const makeCache = () => new Map<string, string>();
|
|
10
|
+
|
|
11
|
+
it("dims a single truecolor fg escape", () => {
|
|
12
|
+
const cache = makeCache();
|
|
13
|
+
const input = "\x1b[38;2;255;128;64mhello";
|
|
14
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
15
|
+
// Output should still be a truecolor fg escape (38;2;...)
|
|
16
|
+
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
17
|
+
// But the color should be different (dimmed)
|
|
18
|
+
expect(result).not.toBe(input);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("dims multiple fg escapes in one line", () => {
|
|
22
|
+
const cache = makeCache();
|
|
23
|
+
const input = "\x1b[38;2;255;0;0mred\x1b[38;2;0;0;255mblue";
|
|
24
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
25
|
+
// Both escapes should be rewritten to dimmed versions
|
|
26
|
+
expect(result).not.toContain("\x1b[38;2;255;0;0m");
|
|
27
|
+
expect(result).not.toContain("\x1b[38;2;0;0;255m");
|
|
28
|
+
// Text content preserved
|
|
29
|
+
expect(stripAnsi(result)).toBe("redblue");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("leaves lines with no fg escapes unchanged", () => {
|
|
33
|
+
const cache = makeCache();
|
|
34
|
+
const input = "plain text without escapes";
|
|
35
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
36
|
+
expect(result).toBe(input);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves non-fg escapes (bold, italic, reset)", () => {
|
|
40
|
+
const cache = makeCache();
|
|
41
|
+
const input = "\x1b[1mbold\x1b[0m \x1b[3mitalic\x1b[23m";
|
|
42
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
43
|
+
expect(result).toContain("\x1b[1m");
|
|
44
|
+
expect(result).toContain("\x1b[0m");
|
|
45
|
+
expect(result).toContain("\x1b[3m");
|
|
46
|
+
expect(result).toContain("\x1b[23m");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("cache hit returns same result", () => {
|
|
50
|
+
const cache = makeCache();
|
|
51
|
+
const escape = "\x1b[38;2;200;100;50m";
|
|
52
|
+
const input = `${escape}text`;
|
|
53
|
+
const first = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
54
|
+
const second = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
55
|
+
expect(first).toBe(second);
|
|
56
|
+
// Cache should have the entry
|
|
57
|
+
expect(cache.has(escape)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("passes through unrecognized escapes unchanged", () => {
|
|
61
|
+
const cache = makeCache();
|
|
62
|
+
// A bg escape (48;2;...) should not be matched by FG_COLOR_ESCAPE_RE
|
|
63
|
+
const input = "\x1b[48;2;10;10;10mbg text";
|
|
64
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
65
|
+
expect(result).toBe(input);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("dims 256-palette fg escapes to truecolor", () => {
|
|
69
|
+
const cache = makeCache();
|
|
70
|
+
const input = "\x1b[38;5;196mred"; // 256-palette red
|
|
71
|
+
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
72
|
+
// Output should be truecolor (38;2;...), not 256-palette
|
|
73
|
+
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mred/);
|
|
74
|
+
expect(result).not.toContain("\x1b[38;5;");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles empty string", () => {
|
|
78
|
+
const cache = makeCache();
|
|
79
|
+
expect(dimAnsiLine("", 0.4, 0.5, cache)).toBe("");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("property: preserves non-ANSI text content", () => {
|
|
83
|
+
fc.assert(
|
|
84
|
+
fc.property(
|
|
85
|
+
fc.string({ maxLength: 200 }),
|
|
86
|
+
(s) => {
|
|
87
|
+
const cache = makeCache();
|
|
88
|
+
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
89
|
+
return stripAnsi(s) === stripAnsi(result);
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("property: never introduces 38;5; escapes (only produces truecolor 38;2;)", () => {
|
|
96
|
+
fc.assert(
|
|
97
|
+
fc.property(
|
|
98
|
+
fc.string({ maxLength: 200 }),
|
|
99
|
+
(s) => {
|
|
100
|
+
const cache = makeCache();
|
|
101
|
+
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
102
|
+
// Any 38;5; in output must have been in the input
|
|
103
|
+
// (dimAnsiLine only produces 38;2; output)
|
|
104
|
+
const inputHas256 = s.includes("38;5;");
|
|
105
|
+
const outputHas256 = result.includes("38;5;");
|
|
106
|
+
// If input didn't have 38;5;, output shouldn't either
|
|
107
|
+
// (because we only dim via truecolor)
|
|
108
|
+
if (!inputHas256) {
|
|
109
|
+
return !outputHas256;
|
|
110
|
+
}
|
|
111
|
+
// If input had 38;5;, they get replaced with 38;2;
|
|
112
|
+
// So output should NOT have 38;5; either
|
|
113
|
+
return !outputHas256;
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── buildMutedMarkdownTheme ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe("buildMutedMarkdownTheme", () => {
|
|
123
|
+
const mockTheme = {
|
|
124
|
+
fg: (token: string, text: string) => `\x1b[38;2;180;180;180m${text}\x1b[0m`,
|
|
125
|
+
getFgAnsi: (token: string) => "\x1b[38;2;180;180;180m",
|
|
126
|
+
italic: (text: string) => `\x1b[3m${text}\x1b[23m`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
it("returns MarkdownTheme with all required fields", () => {
|
|
130
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
131
|
+
expect(typeof theme.codeBlockIndent).toBe("string");
|
|
132
|
+
expect(typeof theme.heading).toBe("function");
|
|
133
|
+
expect(typeof theme.link).toBe("function");
|
|
134
|
+
expect(typeof theme.linkUrl).toBe("function");
|
|
135
|
+
expect(typeof theme.code).toBe("function");
|
|
136
|
+
expect(typeof theme.codeBlock).toBe("function");
|
|
137
|
+
expect(typeof theme.codeBlockBorder).toBe("function");
|
|
138
|
+
expect(typeof theme.quote).toBe("function");
|
|
139
|
+
expect(typeof theme.quoteBorder).toBe("function");
|
|
140
|
+
expect(typeof theme.hr).toBe("function");
|
|
141
|
+
expect(typeof theme.listBullet).toBe("function");
|
|
142
|
+
expect(typeof theme.bold).toBe("function");
|
|
143
|
+
expect(typeof theme.italic).toBe("function");
|
|
144
|
+
expect(typeof theme.strikethrough).toBe("function");
|
|
145
|
+
expect(typeof theme.underline).toBe("function");
|
|
146
|
+
expect(typeof theme.highlightCode).toBe("function");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("heading uses gold color (#FFD700)", () => {
|
|
150
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
151
|
+
const result = theme.heading("Title");
|
|
152
|
+
// Gold is #FFD700 = rgb(255, 215, 0)
|
|
153
|
+
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
154
|
+
expect(result).toContain("Title");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("bold uses gold color (#FFD700)", () => {
|
|
158
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
159
|
+
const result = theme.bold("Strong");
|
|
160
|
+
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
161
|
+
expect(result).toContain("Strong");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("codeBlock uses thinkingText", () => {
|
|
165
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
166
|
+
const result = theme.codeBlock("code here");
|
|
167
|
+
expect(result).toContain("code here");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("italic wraps with ANSI italic codes", () => {
|
|
171
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
172
|
+
const result = theme.italic("slanted");
|
|
173
|
+
expect(result).toContain("\x1b[3m");
|
|
174
|
+
expect(result).toContain("\x1b[23m");
|
|
175
|
+
expect(result).toContain("slanted");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("codeBlockIndent is empty string", () => {
|
|
179
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
180
|
+
expect(theme.codeBlockIndent).toBe("");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("highlightCode returns array of strings", () => {
|
|
184
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
185
|
+
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
186
|
+
expect(Array.isArray(result)).toBe(true);
|
|
187
|
+
expect(result.length).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("highlightCode dims ANSI escapes in output", () => {
|
|
191
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
192
|
+
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
193
|
+
// Output should contain dimmed truecolor escapes
|
|
194
|
+
const joined = result.join("\n");
|
|
195
|
+
expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("different saturationFactor produces different dimmed output", () => {
|
|
199
|
+
const cache = new Map<string, string>();
|
|
200
|
+
const input = "\x1b[38;2;255;128;64mhello";
|
|
201
|
+
const resultLow = dimAnsiLine(input, 0.4, 0.3, cache);
|
|
202
|
+
const cache2 = new Map<string, string>();
|
|
203
|
+
const resultHigh = dimAnsiLine(input, 0.4, 0.7, cache2);
|
|
204
|
+
// Same input with different saturation factors must produce different escapes
|
|
205
|
+
expect(resultLow).not.toBe(resultHigh);
|
|
206
|
+
// Both must still be valid truecolor fg escapes
|
|
207
|
+
expect(resultLow).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
208
|
+
expect(resultHigh).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("accepts custom codeDefaultLightness", () => {
|
|
212
|
+
const theme = buildMutedMarkdownTheme(mockTheme as any, { codeDefaultLightness: 0.9 });
|
|
213
|
+
const result = theme.highlightCode!("test", "plaintext");
|
|
214
|
+
expect(Array.isArray(result)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { transformThinkingContent } from "./transform.js";
|
|
3
|
+
|
|
4
|
+
describe("transformThinkingContent", () => {
|
|
5
|
+
it("modifies thinking content on assistant messages", () => {
|
|
6
|
+
const message = {
|
|
7
|
+
role: "assistant" as const,
|
|
8
|
+
content: [
|
|
9
|
+
{ type: "thinking" as const, thinking: " some thinking " },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
transformThinkingContent(message);
|
|
13
|
+
expect(message.content[0]!.thinking).toBe("some thinking");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("skips non-assistant messages", () => {
|
|
17
|
+
const message = {
|
|
18
|
+
role: "user" as const,
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "thinking" as const, thinking: " should not change " },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
transformThinkingContent(message);
|
|
24
|
+
expect(message.content[0]!.thinking).toBe(" should not change ");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips empty thinking", () => {
|
|
28
|
+
const message = {
|
|
29
|
+
role: "assistant" as const,
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "thinking" as const, thinking: " " },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
transformThinkingContent(message);
|
|
35
|
+
expect(message.content[0]!.thinking).toBe(" ");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("skips thinking with undefined value", () => {
|
|
39
|
+
const message = {
|
|
40
|
+
role: "assistant" as const,
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "thinking" as const } as { type: string; thinking?: string },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
transformThinkingContent(message);
|
|
46
|
+
expect(message.content[0]!.thinking).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls unindentCodeBlocks on thinking content", () => {
|
|
50
|
+
const message = {
|
|
51
|
+
role: "assistant" as const,
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "thinking" as const,
|
|
55
|
+
thinking: "```\n indented code\n more code\n```",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
transformThinkingContent(message);
|
|
60
|
+
expect(message.content[0]!.thinking).toBe("```\nindented code\nmore code\n```");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles mixed content types", () => {
|
|
64
|
+
const message = {
|
|
65
|
+
role: "assistant" as const,
|
|
66
|
+
content: [
|
|
67
|
+
{ type: "text" as const, text: "some text" },
|
|
68
|
+
{ type: "thinking" as const, thinking: " thinking content " },
|
|
69
|
+
{ type: "text" as const, text: "more text" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
transformThinkingContent(message);
|
|
73
|
+
expect(message.content[0]).toEqual({ type: "text", text: "some text" });
|
|
74
|
+
expect(message.content[1]!.thinking).toBe("thinking content");
|
|
75
|
+
expect(message.content[2]).toEqual({ type: "text", text: "more text" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("after transform, thinking is trimmed", () => {
|
|
79
|
+
const message = {
|
|
80
|
+
role: "assistant" as const,
|
|
81
|
+
content: [
|
|
82
|
+
{ type: "thinking" as const, thinking: " hello world " },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
transformThinkingContent(message);
|
|
86
|
+
const thinking = message.content[0]!.thinking;
|
|
87
|
+
expect(thinking).toBe(thinking?.trim());
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { unindentCodeBlocks } from "./unindent.js";
|
|
4
|
+
|
|
5
|
+
describe("unindentCodeBlocks", () => {
|
|
6
|
+
it("strips common leading whitespace from fenced code blocks", () => {
|
|
7
|
+
const input = "```\n line1\n line2\n```";
|
|
8
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("preserves empty lines structure", () => {
|
|
12
|
+
const input = "```\n line1\n\n line2\n```";
|
|
13
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\n\nline2\n```");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("leaves whitespace-only blocks untouched", () => {
|
|
17
|
+
const input = "```\n \n \n```";
|
|
18
|
+
expect(unindentCodeBlocks(input)).toBe("```\n \n \n```");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles CRLF → LF normalization", () => {
|
|
22
|
+
const input = "```\r\n line1\r\n line2\r\n```";
|
|
23
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles blocks with 0 indent on some lines (no stripping)", () => {
|
|
27
|
+
const input = "```\n indented\nnotindented\n indented\n```";
|
|
28
|
+
expect(unindentCodeBlocks(input)).toBe("```\n indented\nnotindented\n indented\n```");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips trailing empty lines from code blocks", () => {
|
|
32
|
+
const input = "```\n line1\n line2\n\n\n```";
|
|
33
|
+
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles language tags", () => {
|
|
37
|
+
const input = "```python\n def foo():\n pass\n```";
|
|
38
|
+
expect(unindentCodeBlocks(input)).toBe("```python\ndef foo():\n pass\n```");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves text outside code blocks unchanged", () => {
|
|
42
|
+
const input = "Some text\n```\n code\n```\nMore text";
|
|
43
|
+
expect(unindentCodeBlocks(input)).toBe("Some text\n```\ncode\n```\nMore text");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles multiple code blocks", () => {
|
|
47
|
+
const input = "```\n block1\n```\n```\n block2\n```";
|
|
48
|
+
expect(unindentCodeBlocks(input)).toBe("```\nblock1\n```\n```\nblock2\n```");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles empty code blocks with content", () => {
|
|
52
|
+
const input = "```\n```";
|
|
53
|
+
expect(unindentCodeBlocks(input)).toBe("```\n```");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("property: idempotence — unindentCodeBlocks(unindentCodeBlocks(x)) === unindentCodeBlocks(x)", () => {
|
|
57
|
+
fc.assert(
|
|
58
|
+
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
59
|
+
const once = unindentCodeBlocks(s);
|
|
60
|
+
const twice = unindentCodeBlocks(once);
|
|
61
|
+
return once === twice;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("property: output never contains CRLF (\\r\\n)", () => {
|
|
67
|
+
fc.assert(
|
|
68
|
+
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
69
|
+
const result = unindentCodeBlocks(s);
|
|
70
|
+
return !result.includes("\r\n");
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|