@pi-archimedes/core 2.8.0 → 2.9.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/README.md +6 -27
- package/package.json +7 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +97 -18
- package/src/bridge/index.test.ts +147 -1
- package/src/bridge/index.ts +52 -2
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -354
- package/src/index.ts +9 -272
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
4
|
-
|
|
5
|
-
// Force TRUECOLOR=false so formatColumns output is deterministic across envs
|
|
6
|
-
vi.mock("./logo.js", async (importOriginal) => ({ ...(await importOriginal()), TRUECOLOR: false }));
|
|
7
|
-
|
|
8
|
-
// Mock pi-tui — visibleWidth and truncateToWidth
|
|
9
|
-
vi.mock("@earendil-works/pi-tui", () => ({
|
|
10
|
-
visibleWidth: (s: string) => s.length,
|
|
11
|
-
truncateToWidth: (s: string, w: number) => s.slice(0, w),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
detectSection,
|
|
18
|
-
parseSectionText,
|
|
19
|
-
parseModelScope,
|
|
20
|
-
extractName,
|
|
21
|
-
formatColumns,
|
|
22
|
-
buildItemWrapper,
|
|
23
|
-
SECTION_KEYS,
|
|
24
|
-
RAMP_FRAMES,
|
|
25
|
-
} from "./sections.js";
|
|
26
|
-
|
|
27
|
-
// ── detectSection ────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
describe("detectSection", () => {
|
|
30
|
-
it("finds Models section key", () => {
|
|
31
|
-
expect(detectSection("[Models]\nclaude-sonnet")).toBe("Models");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("finds Context section key", () => {
|
|
35
|
-
expect(detectSection("some text [Context] more")).toBe("Context");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("finds Prompts section key", () => {
|
|
39
|
-
expect(detectSection("[Prompts]")).toBe("Prompts");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("finds Skills section key", () => {
|
|
43
|
-
expect(detectSection("Skills: [Skills] list")).toBe("Skills");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("finds Extensions section key", () => {
|
|
47
|
-
expect(detectSection("[Extensions]\nfoo")).toBe("Extensions");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("finds Themes section key", () => {
|
|
51
|
-
expect(detectSection("[Themes]\ndark")).toBe("Themes");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("returns undefined for non-section text", () => {
|
|
55
|
-
expect(detectSection("just some random text")).toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("returns undefined for empty string", () => {
|
|
59
|
-
expect(detectSection("")).toBeUndefined();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("returns undefined for bracketed text that is not a section key", () => {
|
|
63
|
-
expect(detectSection("[NotASection]")).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("returns the first matching section key", () => {
|
|
67
|
-
const text = "[Models]\n[Context]";
|
|
68
|
-
expect(detectSection(text)).toBe("Models");
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// ── parseSectionText ─────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
describe("parseSectionText", () => {
|
|
75
|
-
it("extracts items correctly", () => {
|
|
76
|
-
const text = "[Models]\nclaude-sonnet\ngpt-4";
|
|
77
|
-
const result = parseSectionText(text);
|
|
78
|
-
expect(result).not.toBeUndefined();
|
|
79
|
-
expect(result!.name).toBe("Models");
|
|
80
|
-
expect(result!.items).toContain("claude-sonnet");
|
|
81
|
-
expect(result!.items).toContain("gpt-4");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("returns undefined for non-section text", () => {
|
|
85
|
-
expect(parseSectionText("no section here")).toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("deduplicates items", () => {
|
|
89
|
-
const text = "[Models]\nclaude-sonnet\nclaude-sonnet\ngpt-4";
|
|
90
|
-
const result = parseSectionText(text);
|
|
91
|
-
expect(result).not.toBeUndefined();
|
|
92
|
-
const sonnetCount = result!.items.filter(i => i === "claude-sonnet").length;
|
|
93
|
-
expect(sonnetCount).toBe(1);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("prefers prefixed over bare names", () => {
|
|
97
|
-
const text = "[Extensions]\nnpm:@foo/bar\n@foo/bar";
|
|
98
|
-
const result = parseSectionText(text);
|
|
99
|
-
expect(result).not.toBeUndefined();
|
|
100
|
-
// Should prefer the prefixed version
|
|
101
|
-
expect(result!.items.some(i => i.startsWith("npm:"))).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("skips empty lines and bracket lines", () => {
|
|
105
|
-
const text = "[Themes]\n\ndark\n[Other]\nlight";
|
|
106
|
-
const result = parseSectionText(text);
|
|
107
|
-
expect(result).not.toBeUndefined();
|
|
108
|
-
expect(result!.items).toContain("dark");
|
|
109
|
-
// "light" is under [Other] which isn't a recognized section,
|
|
110
|
-
// but since we detected [Themes] first, items under [Other] should be skipped
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("handles empty section (no items)", () => {
|
|
114
|
-
const text = "[Models]";
|
|
115
|
-
const result = parseSectionText(text);
|
|
116
|
-
expect(result).not.toBeUndefined();
|
|
117
|
-
expect(result!.items.length).toBe(0);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ── parseModelScope ──────────────────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
describe("parseModelScope", () => {
|
|
124
|
-
it("extracts model names from 'Model scope:' line", () => {
|
|
125
|
-
const text = "Model scope: claude-sonnet, gpt-4";
|
|
126
|
-
const result = parseModelScope(text);
|
|
127
|
-
expect(result).not.toBeUndefined();
|
|
128
|
-
expect(result!.name).toBe("Models");
|
|
129
|
-
expect(result!.items).toContain("claude-sonnet");
|
|
130
|
-
expect(result!.items).toContain("gpt-4");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("strips keyboard shortcut hints", () => {
|
|
134
|
-
const text = "Model scope: claude-sonnet (Ctrl+1), gpt-4 (Ctrl+2)";
|
|
135
|
-
const result = parseModelScope(text);
|
|
136
|
-
expect(result).not.toBeUndefined();
|
|
137
|
-
expect(result!.items).toContain("claude-sonnet");
|
|
138
|
-
expect(result!.items).not.toContain("(Ctrl+1)");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("returns undefined when no Model scope line", () => {
|
|
142
|
-
expect(parseModelScope("some random text")).toBeUndefined();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("returns undefined for empty items", () => {
|
|
146
|
-
expect(parseModelScope("Model scope: ")).toBeUndefined();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("handles single model", () => {
|
|
150
|
-
const result = parseModelScope("Model scope: only-one");
|
|
151
|
-
expect(result).not.toBeUndefined();
|
|
152
|
-
expect(result!.items).toEqual(["only-one"]);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// ── extractName ──────────────────────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
describe("extractName", () => {
|
|
159
|
-
it("extracts Models name from path", () => {
|
|
160
|
-
expect(extractName("/path/to/claude-sonnet", "Models")).toBe("claude-sonnet");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("extracts Themes name from path", () => {
|
|
164
|
-
expect(extractName("/path/to/dark-theme", "Themes")).toBe("dark-theme");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("extracts Prompts name (strips extension)", () => {
|
|
168
|
-
expect(extractName("/path/to/my-prompt.ts", "Prompts")).toBe("my-prompt");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("extracts Context name (basename only)", () => {
|
|
172
|
-
expect(extractName("/path/to/context-file.md", "Context")).toBe("context-file.md");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("extracts Skills name from SKILL.md path", () => {
|
|
176
|
-
expect(extractName("/path/to/my-skill/SKILL.md", "Skills")).toBe("my-skill");
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("extracts Skills name from SKILL.ts path", () => {
|
|
180
|
-
expect(extractName("/path/to/my-skill/SKILL.ts", "Skills")).toBe("my-skill");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("handles npm: prefix for Extensions", () => {
|
|
184
|
-
expect(extractName("npm:@foo/bar", "Extensions")).toBe("npm:bar");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("handles git: prefix for Extensions", () => {
|
|
188
|
-
expect(extractName("git:github.com/user/repo", "Extensions")).toBe("git:repo");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("strips file extensions for Models", () => {
|
|
192
|
-
expect(extractName("/path/to/model.ts", "Models")).toBe("model");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("handles simple name without path", () => {
|
|
196
|
-
expect(extractName("simple-name", "Models")).toBe("simple-name");
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// ── formatColumns ────────────────────────────────────────────────────────────
|
|
201
|
-
|
|
202
|
-
describe("formatColumns", () => {
|
|
203
|
-
const mockTheme = {
|
|
204
|
-
fg: (token: string, text: string) => text,
|
|
205
|
-
getFgAnsi: (token: string) => "",
|
|
206
|
-
} as any;
|
|
207
|
-
|
|
208
|
-
const mockRef = {
|
|
209
|
-
frame: 100,
|
|
210
|
-
revealed: true,
|
|
211
|
-
revealedAt: 0,
|
|
212
|
-
scaffoldAt: 0,
|
|
213
|
-
settled: true,
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
it("returns empty array for empty sections", () => {
|
|
217
|
-
expect(formatColumns([], mockTheme, 80, mockRef)).toEqual([]);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("returns empty array for sections with no items", () => {
|
|
221
|
-
const sections = [{ name: "Models" as const, items: [] }];
|
|
222
|
-
expect(formatColumns(sections, mockTheme, 80, mockRef)).toEqual([]);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("formats a single section with items", () => {
|
|
226
|
-
const sections = [{ name: "Models" as const, items: ["claude-sonnet", "gpt-4"] }];
|
|
227
|
-
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
228
|
-
expect(result.length).toBeGreaterThan(0);
|
|
229
|
-
// Should contain the section header
|
|
230
|
-
expect(result.some(line => line.includes("[Models]"))).toBe(true);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("formats multiple sections", () => {
|
|
234
|
-
const sections = [
|
|
235
|
-
{ name: "Models" as const, items: ["claude"] },
|
|
236
|
-
{ name: "Themes" as const, items: ["dark"] },
|
|
237
|
-
];
|
|
238
|
-
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
239
|
-
expect(result.some(line => line.includes("[Models]"))).toBe(true);
|
|
240
|
-
expect(result.some(line => line.includes("[Themes]"))).toBe(true);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("adds blank line after Version section", () => {
|
|
244
|
-
const sections = [{ name: "Version" as const, items: ["1.0.0"] }];
|
|
245
|
-
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
246
|
-
expect(result).toContain("");
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("wraps items to new lines when exceeding width", () => {
|
|
250
|
-
const longItems = Array.from({ length: 20 }, (_, i) => `model-${i}`);
|
|
251
|
-
const sections = [{ name: "Models" as const, items: longItems }];
|
|
252
|
-
const result = formatColumns(sections, mockTheme, 40, mockRef);
|
|
253
|
-
// With narrow width, items should wrap to multiple lines
|
|
254
|
-
expect(result.length).toBeGreaterThan(1);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it("handles unrevealed state", () => {
|
|
258
|
-
const sections = [{ name: "Models" as const, items: ["test"] }];
|
|
259
|
-
const unrevealedRef = { ...mockRef, revealed: false };
|
|
260
|
-
const result = formatColumns(sections, mockTheme, 80, unrevealedRef);
|
|
261
|
-
expect(result.length).toBeGreaterThan(0);
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// ── buildItemWrapper ─────────────────────────────────────────────────────────
|
|
266
|
-
|
|
267
|
-
describe("buildItemWrapper", () => {
|
|
268
|
-
const muted = (t: string) => `\x1b[90m${t}\x1b[0m`;
|
|
269
|
-
|
|
270
|
-
it("returns identity function when not revealed", () => {
|
|
271
|
-
const wrapper = buildItemWrapper(0, false, undefined, undefined, muted);
|
|
272
|
-
expect(wrapper("hello")).toBe("hello");
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("returns muted function when no RGB data", () => {
|
|
276
|
-
const wrapper = buildItemWrapper(10, true, undefined, undefined, muted);
|
|
277
|
-
expect(wrapper("hello")).toBe(muted("hello"));
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("returns muted function when ramp is complete", () => {
|
|
281
|
-
const startRgb = [20, 20, 20] as [number, number, number];
|
|
282
|
-
const mutedRgb = [100, 100, 100] as [number, number, number];
|
|
283
|
-
const wrapper = buildItemWrapper(RAMP_FRAMES, true, startRgb, mutedRgb, muted);
|
|
284
|
-
expect(wrapper("hello")).toBe(muted("hello"));
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("returns rgb-colored function during ramp", () => {
|
|
288
|
-
const startRgb = [20, 20, 20] as [number, number, number];
|
|
289
|
-
const mutedRgb = [200, 200, 200] as [number, number, number];
|
|
290
|
-
const wrapper = buildItemWrapper(1, true, startRgb, mutedRgb, muted);
|
|
291
|
-
const result = wrapper("hello");
|
|
292
|
-
// Should be a truecolor ANSI escape, not the muted fallback
|
|
293
|
-
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello\x1b\[0m/);
|
|
294
|
-
expect(result).not.toContain("\x1b[90m");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("lerps colors correctly at midpoint", () => {
|
|
298
|
-
const startRgb = [0, 0, 0] as [number, number, number];
|
|
299
|
-
const endRgb = [200, 200, 200] as [number, number, number];
|
|
300
|
-
// At exactly RAMP_FRAMES / 2, t = 0.5, eased = 0.75
|
|
301
|
-
const midAge = Math.floor(RAMP_FRAMES / 2);
|
|
302
|
-
const wrapper = buildItemWrapper(midAge, true, startRgb, endRgb, muted);
|
|
303
|
-
const result = wrapper("x");
|
|
304
|
-
// eased = 1 - (1-0.5)^2 = 0.75
|
|
305
|
-
// lerp(0, 200, 0.75) = 150
|
|
306
|
-
expect(result).toContain("\x1b[38;2;150;150;150mx\x1b[0m");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("returns muted when mutedRgb is undefined", () => {
|
|
310
|
-
const startRgb = [20, 20, 20] as [number, number, number];
|
|
311
|
-
const wrapper = buildItemWrapper(5, true, startRgb, undefined, muted);
|
|
312
|
-
expect(wrapper("hello")).toBe(muted("hello"));
|
|
313
|
-
});
|
|
314
|
-
});
|
package/src/startup/sections.ts
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import { clampLine, stripAnsi } from "../text.js";
|
|
2
|
-
import { gray, rgb, extractRgb, lerp } from "../color.js";
|
|
3
|
-
import { TRUECOLOR } from "./logo.js";
|
|
4
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
-
|
|
7
|
-
// ── Types ──────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
export const SECTION_KEYS = ["Models", "Context", "Prompts", "Skills", "Extensions", "Themes"] as const;
|
|
10
|
-
export type SectionKey = (typeof SECTION_KEYS)[number];
|
|
11
|
-
type RenderSectionKey = SectionKey | "Version";
|
|
12
|
-
|
|
13
|
-
export interface ParsedSection {
|
|
14
|
-
name: SectionKey;
|
|
15
|
-
items: string[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface RenderSection {
|
|
19
|
-
name: RenderSectionKey;
|
|
20
|
-
items: string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ── Animation constants ────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
export const RAMP_FRAMES = 22;
|
|
26
|
-
export const STAGGER_FRAMES = 0;
|
|
27
|
-
export const BASE_FADE_DELAY = 3;
|
|
28
|
-
export const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
|
|
29
|
-
|
|
30
|
-
// ── Section detection & parsing ────────────────────────────────
|
|
31
|
-
|
|
32
|
-
export function detectSection(plain: string): SectionKey | undefined {
|
|
33
|
-
for (const key of SECTION_KEYS) {
|
|
34
|
-
if (plain.includes(`[${key}]`)) return key;
|
|
35
|
-
}
|
|
36
|
-
return undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function parseSectionText(plain: string): ParsedSection | undefined {
|
|
40
|
-
const sectionName = detectSection(plain);
|
|
41
|
-
if (!sectionName) return undefined;
|
|
42
|
-
|
|
43
|
-
const names = extractItemsFromSection(plain, sectionName);
|
|
44
|
-
const items = deduplicateItems(names);
|
|
45
|
-
return { name: sectionName, items };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Extract item names from a section's plain text.
|
|
50
|
-
* Tracks source prefixes (npm:/git:) for Extensions/Skills sections.
|
|
51
|
-
*/
|
|
52
|
-
function extractItemsFromSection(plain: string, sectionName: SectionKey): string[] {
|
|
53
|
-
const names: string[] = [];
|
|
54
|
-
const lines = plain.split("\n");
|
|
55
|
-
let currentSource = "";
|
|
56
|
-
let sourceIndent = 0;
|
|
57
|
-
const showSource = sectionName === "Extensions" || sectionName === "Skills";
|
|
58
|
-
|
|
59
|
-
for (const line of lines) {
|
|
60
|
-
const trimmed = line.trim();
|
|
61
|
-
if (shouldSkipLine(trimmed, currentSource)) continue;
|
|
62
|
-
|
|
63
|
-
const indent = line.length - line.trimStart().length;
|
|
64
|
-
|
|
65
|
-
// Track package source headers (e.g. "git:github.com/...", "npm:@foo/bar")
|
|
66
|
-
if (/^(git:|npm:)\S+\//.test(trimmed)) {
|
|
67
|
-
currentSource = trimmed.startsWith("git:") ? "git:" : "npm:";
|
|
68
|
-
sourceIndent = indent;
|
|
69
|
-
const name = extractName(trimmed, sectionName);
|
|
70
|
-
if (name && showSource) names.push(name);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Reset source prefix when indent returns to source level or shallower
|
|
75
|
-
if (currentSource && indent <= sourceIndent) {
|
|
76
|
-
currentSource = "";
|
|
77
|
-
sourceIndent = 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const name = extractName(trimmed, sectionName);
|
|
81
|
-
if (name) names.push(showSource && currentSource ? currentSource + name : name);
|
|
82
|
-
}
|
|
83
|
-
return names;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Check if a line should be skipped during section parsing. */
|
|
87
|
-
function shouldSkipLine(trimmed: string, currentSource: string): boolean {
|
|
88
|
-
if (!trimmed) return true;
|
|
89
|
-
if (trimmed.startsWith("[")) return true;
|
|
90
|
-
if (/^(user|project|path)$/.test(trimmed)) return true;
|
|
91
|
-
if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) return true;
|
|
92
|
-
// Skip resolved file paths only under source headers
|
|
93
|
-
if (currentSource) {
|
|
94
|
-
if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) return true;
|
|
95
|
-
if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) return true;
|
|
96
|
-
}
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Deduplicate items by bare name (without prefix).
|
|
102
|
-
* Prefers prefixed versions (npm:/git:) over bare names.
|
|
103
|
-
*/
|
|
104
|
-
function deduplicateItems(names: string[]): string[] {
|
|
105
|
-
const seen = new Map<string, string>();
|
|
106
|
-
for (const n of names) {
|
|
107
|
-
if (/^(index|dist|src|out|lib|bin)$/i.test(n)) continue;
|
|
108
|
-
const bare = n.replace(/^(npm:|git:)/, "");
|
|
109
|
-
if (!seen.has(bare) || n.includes(":")) seen.set(bare, n);
|
|
110
|
-
}
|
|
111
|
-
return [...seen.values()];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function parseModelScope(plain: string): ParsedSection | undefined {
|
|
115
|
-
const m = plain.match(/Model scope:\s*(.+)/i);
|
|
116
|
-
if (!m) return undefined;
|
|
117
|
-
const raw = m[1]!.replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
|
|
118
|
-
const items = raw.split(",").map(s => s.trim()).filter(Boolean);
|
|
119
|
-
return items.length > 0 ? { name: "Models", items } : undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Name extraction helpers ────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
function detectOrigin(path: string): { prefix: string; clean: string } {
|
|
125
|
-
if (/^npm:/.test(path)) return { prefix: "npm:", clean: path.slice(4) };
|
|
126
|
-
if (/^git:/.test(path)) return { prefix: "git:", clean: path.slice(4) };
|
|
127
|
-
if (/^https?:\/\//.test(path)) return { prefix: "git:", clean: path };
|
|
128
|
-
return { prefix: "", clean: path };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function cleanName(name: string): string {
|
|
132
|
-
return name.replace(/\.(ts|js|json|md|git)$/i, "");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type Extractor = (clean: string, prefix: string) => string;
|
|
136
|
-
|
|
137
|
-
const defaultExtract: Extractor = (clean, prefix) =>
|
|
138
|
-
prefix + cleanName(clean.split("/").pop() ?? clean);
|
|
139
|
-
|
|
140
|
-
const sectionExtractors: Record<SectionKey, Extractor> = {
|
|
141
|
-
Models: defaultExtract,
|
|
142
|
-
Themes: defaultExtract,
|
|
143
|
-
Prompts: (clean) => {
|
|
144
|
-
const base = clean.split("/").pop() ?? clean;
|
|
145
|
-
return cleanName(base) || clean;
|
|
146
|
-
},
|
|
147
|
-
Context: (clean) => clean.split("/").pop() ?? clean,
|
|
148
|
-
Skills: (clean, prefix) => {
|
|
149
|
-
if (!clean.includes("/")) return defaultExtract(clean, prefix);
|
|
150
|
-
const parts = clean.split("/");
|
|
151
|
-
const file = parts.pop() ?? "";
|
|
152
|
-
if (/^SKILL\.(md|ts|js)$/i.test(file)) {
|
|
153
|
-
return prefix + cleanName(parts.pop() ?? file);
|
|
154
|
-
}
|
|
155
|
-
return prefix + cleanName(file);
|
|
156
|
-
},
|
|
157
|
-
Extensions: (clean, prefix) => {
|
|
158
|
-
const stripped = clean.replace(/^https?:\/\/[^/]+\//, "");
|
|
159
|
-
const parts = stripped.split("/").filter(p => {
|
|
160
|
-
const lower = p.toLowerCase();
|
|
161
|
-
return p && !/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/.test(lower);
|
|
162
|
-
});
|
|
163
|
-
if (parts.length > 0) return prefix + cleanName(parts.pop()!);
|
|
164
|
-
return prefix + cleanName(clean);
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export function extractName(path: string, section: SectionKey): string {
|
|
169
|
-
const trimmed = path.trim();
|
|
170
|
-
const { prefix, clean } = detectOrigin(trimmed);
|
|
171
|
-
return sectionExtractors[section](clean, prefix);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ── Column formatter ───────────────────────────────────────────
|
|
175
|
-
|
|
176
|
-
function pad(s: string, w: number): string {
|
|
177
|
-
const vw = visibleWidth(s);
|
|
178
|
-
return vw >= w ? s : s + " ".repeat(w - vw);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function formatColumns(sections: RenderSection[], theme: Theme, maxW: number, ref: { frame: number; revealed: boolean; revealedAt: number; scaffoldAt: number; settled: boolean }): string[] {
|
|
182
|
-
if (sections.length === 0) return [];
|
|
183
|
-
|
|
184
|
-
const dim = (t: string) => theme.fg("dim", t);
|
|
185
|
-
const muted = (t: string) => theme.fg("muted", t);
|
|
186
|
-
|
|
187
|
-
const headerW = Math.max(...sections.map(s => s.name.length + 2)) + 2;
|
|
188
|
-
|
|
189
|
-
const itemAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
|
|
190
|
-
const labelAge = ref.revealed ? ref.frame - ref.scaffoldAt : 0;
|
|
191
|
-
|
|
192
|
-
// RGB endpoints for fade ramps (truecolor only)
|
|
193
|
-
const fadeStartRgb: [number, number, number] = [20, 20, 20];
|
|
194
|
-
let dimRgb: [number, number, number] | undefined;
|
|
195
|
-
let mutedRgb: [number, number, number] | undefined;
|
|
196
|
-
const labelRamping = TRUECOLOR && ref.revealed && labelAge < RAMP_FRAMES + MAX_STAGGER;
|
|
197
|
-
const itemRamping = TRUECOLOR && ref.revealed && itemAge < RAMP_FRAMES + MAX_STAGGER;
|
|
198
|
-
if (labelRamping || itemRamping) {
|
|
199
|
-
dimRgb = extractRgb(theme.fg("dim", " "));
|
|
200
|
-
mutedRgb = extractRgb(theme.fg("muted", " "));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const lines: string[] = [];
|
|
204
|
-
|
|
205
|
-
for (let si = 0; si < sections.length; si++) {
|
|
206
|
-
const sec = sections[si]!;
|
|
207
|
-
if (sec.items.length === 0) continue;
|
|
208
|
-
|
|
209
|
-
const availableW = maxW - headerW - 1;
|
|
210
|
-
|
|
211
|
-
// Label fade: near-invisible → dim (static dim when not revealed)
|
|
212
|
-
const secLabelAge = Math.max(0, labelAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
213
|
-
const wrapLabel = ref.revealed
|
|
214
|
-
? buildItemWrapper(secLabelAge, true, fadeStartRgb, dimRgb, dim)
|
|
215
|
-
: dim;
|
|
216
|
-
const header = wrapLabel(`[${sec.name}]`);
|
|
217
|
-
const paddedHeader = header + " ".repeat(Math.max(0, headerW - sec.name.length - 2));
|
|
218
|
-
|
|
219
|
-
// Item fade: near-invisible → muted
|
|
220
|
-
const secItemAge = Math.max(0, itemAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
221
|
-
const wrapItems = buildItemWrapper(secItemAge, ref.revealed, fadeStartRgb, mutedRgb, muted);
|
|
222
|
-
|
|
223
|
-
// Style prefix (npm:/git:) dimmer than the name
|
|
224
|
-
const styleItem = (raw: string): string => {
|
|
225
|
-
const prefixMatch = raw.match(/^(npm:|git:)/);
|
|
226
|
-
if (prefixMatch) {
|
|
227
|
-
const pfx = prefixMatch[1]!;
|
|
228
|
-
const name = raw.slice(pfx.length);
|
|
229
|
-
return wrapLabel(pfx) + wrapItems(name);
|
|
230
|
-
}
|
|
231
|
-
return wrapItems(raw);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
let currentLine = "";
|
|
235
|
-
let currentStyled = "";
|
|
236
|
-
let firstLine = true;
|
|
237
|
-
|
|
238
|
-
for (const item of sec.items) {
|
|
239
|
-
const itemW = visibleWidth(item);
|
|
240
|
-
const currentW = visibleWidth(currentLine);
|
|
241
|
-
|
|
242
|
-
if (currentLine && currentW + 2 + itemW > availableW) {
|
|
243
|
-
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
244
|
-
lines.push(clampLine(rawLine, maxW));
|
|
245
|
-
currentLine = item;
|
|
246
|
-
currentStyled = styleItem(item);
|
|
247
|
-
firstLine = false;
|
|
248
|
-
} else {
|
|
249
|
-
currentLine = currentLine ? currentLine + " " + item : item;
|
|
250
|
-
currentStyled = currentStyled ? currentStyled + " " + styleItem(item) : styleItem(item);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (currentLine) {
|
|
254
|
-
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
255
|
-
lines.push(clampLine(rawLine, maxW));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (sec.name === "Version") {
|
|
259
|
-
lines.push("");
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return lines;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export function buildItemWrapper(
|
|
267
|
-
sectionAge: number,
|
|
268
|
-
revealed: boolean,
|
|
269
|
-
startRgb: [number, number, number] | undefined,
|
|
270
|
-
mutedRgb: [number, number, number] | undefined,
|
|
271
|
-
muted: (t: string) => string,
|
|
272
|
-
): (text: string) => string {
|
|
273
|
-
if (!revealed) return (text) => text; // placeholders already styled
|
|
274
|
-
|
|
275
|
-
// No truecolor or ramp done → static muted
|
|
276
|
-
if (!startRgb || !mutedRgb || sectionAge >= RAMP_FRAMES) return muted;
|
|
277
|
-
|
|
278
|
-
const t = Math.min(1, sectionAge / RAMP_FRAMES);
|
|
279
|
-
const eased = 1 - (1 - t) * (1 - t);
|
|
280
|
-
const r = lerp(startRgb[0], mutedRgb[0], eased);
|
|
281
|
-
const g = lerp(startRgb[1], mutedRgb[1], eased);
|
|
282
|
-
const b = lerp(startRgb[2], mutedRgb[2], eased);
|
|
283
|
-
return (text) => rgb(r, g, b, text);
|
|
284
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fc from "fast-check";
|
|
3
|
-
import { compareVersions } from "./version.js";
|
|
4
|
-
|
|
5
|
-
// ── Version generator for property tests ─────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
const versionArb = fc.tuple(fc.nat(100), fc.nat(100), fc.nat(100)).map(
|
|
8
|
-
([major, minor, patch]) => `${major}.${minor}.${patch}`,
|
|
9
|
-
);
|
|
10
|
-
|
|
11
|
-
// ── compareVersions ──────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
describe("compareVersions", () => {
|
|
14
|
-
it('returns 0 for equal versions', () => {
|
|
15
|
-
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('returns 1 when first version is greater', () => {
|
|
19
|
-
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('returns -1 when second version is greater', () => {
|
|
23
|
-
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("handles v prefix on first argument", () => {
|
|
27
|
-
expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("handles v prefix on second argument", () => {
|
|
31
|
-
expect(compareVersions("1.2.3", "v1.2.3")).toBe(0);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("handles v prefix on both arguments", () => {
|
|
35
|
-
expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("handles partial versions (2 parts vs 3 parts)", () => {
|
|
39
|
-
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("handles partial versions with difference", () => {
|
|
43
|
-
expect(compareVersions("1.1", "1.0.5")).toBe(1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("handles zero-padded versions", () => {
|
|
47
|
-
expect(compareVersions("01.02.03", "1.2.3")).toBe(0);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("compares minor version correctly", () => {
|
|
51
|
-
expect(compareVersions("1.2.0", "1.1.9")).toBe(1);
|
|
52
|
-
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("compares patch version correctly", () => {
|
|
56
|
-
expect(compareVersions("1.0.5", "1.0.3")).toBe(1);
|
|
57
|
-
expect(compareVersions("1.0.3", "1.0.5")).toBe(-1);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("handles all-zero versions", () => {
|
|
61
|
-
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// ── Property tests ───────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
it("property: reflexive — compareVersions(a, a) === 0", () => {
|
|
67
|
-
fc.assert(
|
|
68
|
-
fc.property(versionArb, a => {
|
|
69
|
-
return compareVersions(a, a) === 0;
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("property: antisymmetric — compareVersions(a, b) === -compareVersions(b, a)", () => {
|
|
75
|
-
fc.assert(
|
|
76
|
-
fc.property(versionArb, versionArb, (a, b) => {
|
|
77
|
-
return compareVersions(a, b) === -compareVersions(b, a);
|
|
78
|
-
}),
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("property: transitivity — if a<b and b<c then a<c", () => {
|
|
83
|
-
fc.assert(
|
|
84
|
-
fc.property(
|
|
85
|
-
fc.tuple(versionArb, versionArb, versionArb),
|
|
86
|
-
([a, b, c]) => {
|
|
87
|
-
const ab = compareVersions(a, b);
|
|
88
|
-
const bc = compareVersions(b, c);
|
|
89
|
-
const ac = compareVersions(a, c);
|
|
90
|
-
if (ab < 0 && bc < 0) {
|
|
91
|
-
return ac < 0;
|
|
92
|
-
}
|
|
93
|
-
return true;
|
|
94
|
-
},
|
|
95
|
-
),
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
});
|