@pi-archimedes/ui 2.10.4 → 2.11.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 +11 -7
- package/package.json +8 -8
- package/src/bash/index.ts +0 -2
- package/src/bash/renderer.test.ts +50 -5
- package/src/bash/renderer.ts +28 -30
- package/src/codemode/index.ts +0 -9
- package/src/codemode/renderer.test.ts +32 -31
- package/src/codemode/renderer.ts +29 -61
- package/src/config.test.ts +62 -1
- package/src/config.ts +45 -1
- package/src/editor/index.test.ts +118 -2
- package/src/editor/index.ts +34 -3
- package/src/editor/spin-quips.ts +1 -1
- package/src/index.screen-mode.test.ts +411 -0
- package/src/index.test.ts +506 -101
- package/src/index.ts +252 -104
- package/src/settings.test.ts +37 -2
- package/src/settings.ts +16 -3
- package/src/startup/index.test.ts +239 -129
- package/src/startup/index.ts +179 -272
- package/src/startup/pi-listing.test.ts +290 -0
- package/src/startup/sections.test.ts +38 -208
- package/src/startup/sections.ts +83 -236
- package/src/startup-splash-gate.test.ts +546 -0
- package/src/thinking/patch.test.ts +81 -1
- package/src/thinking/patch.ts +22 -6
- package/src/bash/tool.test.ts +0 -26
- package/src/bash/tool.ts +0 -17
- package/src/codemode/loader.test.ts +0 -139
- package/src/codemode/loader.ts +0 -115
- package/src/codemode/tool.test.ts +0 -149
- package/src/codemode/tool.ts +0 -96
- package/src/startup/capture.ts +0 -47
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
package/src/startup/sections.ts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { gray, rgb, extractRgb, lerp } from "@pi-archimedes/core/color";
|
|
1
|
+
import { rgb, extractRgb, lerp } from "@pi-archimedes/core/color";
|
|
3
2
|
import { TRUECOLOR } from "./logo.js";
|
|
4
3
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
5
|
|
|
7
6
|
// ── Types ──────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
//
|
|
8
|
+
// One of these is a block the splash renders: a bracketed label and the items
|
|
9
|
+
// under it. pi 1.x owns its own startup listing, so nothing here detects or
|
|
10
|
+
// parses pi's sections any more (that was the scrape Task 8 removed) — the
|
|
11
|
+
// caller builds these from `ctx.scopedModels` plus the version row, and this
|
|
12
|
+
// module lays them out *in pi's own shape*, because the listing it sits above
|
|
13
|
+
// is pi's: see `formatColumns` for the recipe.
|
|
12
14
|
|
|
13
15
|
export interface ParsedSection {
|
|
14
|
-
name:
|
|
16
|
+
name: string;
|
|
15
17
|
items: string[];
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
interface RenderSection {
|
|
19
|
-
name:
|
|
21
|
+
name: string;
|
|
20
22
|
items: string[];
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -27,280 +29,125 @@ export const STAGGER_FRAMES = 0;
|
|
|
27
29
|
export const BASE_FADE_DELAY = 3;
|
|
28
30
|
export const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
|
|
29
31
|
|
|
30
|
-
|
|
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
|
-
}
|
|
32
|
+
/** pi's compact body indent: `theme.fg("dim", ` ${labels.join(", ")}`)`. */
|
|
33
|
+
const BODY_INDENT = " ";
|
|
79
34
|
|
|
80
|
-
|
|
81
|
-
for (const n of itemNames) {
|
|
82
|
-
names.push(showSource && currentSource ? currentSource + n : n);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return names;
|
|
86
|
-
}
|
|
35
|
+
// ── Section formatter ──────────────────────────────────────────
|
|
87
36
|
|
|
88
37
|
/**
|
|
89
|
-
*
|
|
38
|
+
* Lay the sections out the way pi lays its own startup listing out, because
|
|
39
|
+
* pi's listing is drawn immediately below ours (its `loadedResourcesContainer`
|
|
40
|
+
* is the container after the header's) and any difference in shape reads as a
|
|
41
|
+
* second document. pi's recipe, from `showLoadedResources()` in 1.2.0
|
|
42
|
+
* (`interactive-mode.js:1335-1347`):
|
|
90
43
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
function shouldSkipLine(trimmed: string, currentSource: string): boolean {
|
|
110
|
-
if (!trimmed) return true;
|
|
111
|
-
if (trimmed.startsWith("[")) return true;
|
|
112
|
-
if (/^(user|project|path)$/.test(trimmed)) return true;
|
|
113
|
-
if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) return true;
|
|
114
|
-
// Skip resolved file paths only under source headers
|
|
115
|
-
if (currentSource) {
|
|
116
|
-
if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) return true;
|
|
117
|
-
if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) return true;
|
|
118
|
-
}
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Deduplicate items by bare name (without prefix).
|
|
124
|
-
* Prefers prefixed versions (npm:/git:) over bare names.
|
|
44
|
+
* ```ts
|
|
45
|
+
* const sectionHeader = (name, color = "mdHeading") => theme.fg(color, `[${name}]`);
|
|
46
|
+
* const formatCompactList = (items) => theme.fg("dim", ` ${labels.join(", ")}`);
|
|
47
|
+
* new ExpandableText(() => `${sectionHeader(name)}\n${collapsedBody()}`, …, 0, 0)
|
|
48
|
+
* container.addChild(section); container.addChild(new Spacer(1));
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* So: the bracketed name in `mdHeading` on its own row, the values `dim` on the
|
|
52
|
+
* row under it at a two-space indent, both at column 1, one blank row between
|
|
53
|
+
* sections. Two deliberate differences, both of which pi cannot show and we
|
|
54
|
+
* must:
|
|
55
|
+
*
|
|
56
|
+
* - no trailing blank row. pi puts a `Spacer(1)` above *and* below the header
|
|
57
|
+
* container (`:764-766`) and opens the listing with another, so the gap below
|
|
58
|
+
* the header is already pi's to draw; a blank row here would double it.
|
|
59
|
+
* - long value lists are broken across rows. pi writes one long row and lets
|
|
60
|
+
* its own expander deal with it; our header is a plain component, and a row
|
|
61
|
+
* wider than the frame is a row the terminal wraps under us.
|
|
125
62
|
*/
|
|
126
|
-
function deduplicateItems(names: string[]): string[] {
|
|
127
|
-
const seen = new Map<string, string>();
|
|
128
|
-
for (const n of names) {
|
|
129
|
-
if (/^(index|dist|src|out|lib|bin)$/i.test(n)) continue;
|
|
130
|
-
const bare = n.replace(/^(npm:|git:)/, "");
|
|
131
|
-
if (!seen.has(bare) || n.includes(":")) seen.set(bare, n);
|
|
132
|
-
}
|
|
133
|
-
return [...seen.values()];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function parseModelScope(plain: string): ParsedSection | undefined {
|
|
137
|
-
const m = plain.match(/Model scope:\s*(.+)/i);
|
|
138
|
-
if (!m) return undefined;
|
|
139
|
-
const raw = m[1]!.replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
|
|
140
|
-
const items = raw.split(",").map(s => s.trim()).filter(Boolean);
|
|
141
|
-
return items.length > 0 ? { name: "Models", items } : undefined;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── Name extraction helpers ────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
function detectOrigin(path: string): { prefix: string; clean: string } {
|
|
147
|
-
if (/^npm:/.test(path)) return { prefix: "npm:", clean: path.slice(4) };
|
|
148
|
-
if (/^git:/.test(path)) return { prefix: "git:", clean: path.slice(4) };
|
|
149
|
-
if (/^https?:\/\//.test(path)) return { prefix: "git:", clean: path };
|
|
150
|
-
return { prefix: "", clean: path };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function cleanName(name: string): string {
|
|
154
|
-
return name.replace(/\.(ts|js|json|md|git)$/i, "");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
type Extractor = (clean: string, prefix: string) => string;
|
|
158
|
-
|
|
159
|
-
const defaultExtract: Extractor = (clean, prefix) =>
|
|
160
|
-
prefix + cleanName(clean.split("/").pop() ?? clean);
|
|
161
|
-
|
|
162
|
-
const sectionExtractors: Record<SectionKey, Extractor> = {
|
|
163
|
-
Models: defaultExtract,
|
|
164
|
-
Themes: defaultExtract,
|
|
165
|
-
Prompts: (clean) => {
|
|
166
|
-
const base = clean.split("/").pop() ?? clean;
|
|
167
|
-
return cleanName(base) || clean;
|
|
168
|
-
},
|
|
169
|
-
Context: (clean) => clean.split("/").pop() ?? clean,
|
|
170
|
-
Skills: (clean, prefix) => {
|
|
171
|
-
if (!clean.includes("/")) return defaultExtract(clean, prefix);
|
|
172
|
-
const parts = clean.split("/");
|
|
173
|
-
const file = parts.pop() ?? "";
|
|
174
|
-
if (/^SKILL\.(md|ts|js)$/i.test(file)) {
|
|
175
|
-
return prefix + cleanName(parts.pop() ?? file);
|
|
176
|
-
}
|
|
177
|
-
return prefix + cleanName(file);
|
|
178
|
-
},
|
|
179
|
-
Extensions: (clean, prefix) => {
|
|
180
|
-
const stripped = clean.replace(/^https?:\/\/[^/]+\//, "");
|
|
181
|
-
const parts = stripped.split("/").filter(p => {
|
|
182
|
-
const lower = p.toLowerCase();
|
|
183
|
-
return p && !/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/.test(lower);
|
|
184
|
-
});
|
|
185
|
-
if (parts.length > 0) return prefix + cleanName(parts.pop()!);
|
|
186
|
-
return prefix + cleanName(clean);
|
|
187
|
-
},
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
export function extractName(path: string, section: SectionKey): string {
|
|
191
|
-
const trimmed = path.trim();
|
|
192
|
-
const { prefix, clean } = detectOrigin(trimmed);
|
|
193
|
-
return sectionExtractors[section](clean, prefix);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── Column formatter ───────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
function pad(s: string, w: number): string {
|
|
199
|
-
const vw = visibleWidth(s);
|
|
200
|
-
return vw >= w ? s : s + " ".repeat(w - vw);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
63
|
export function formatColumns(sections: RenderSection[], theme: Theme, maxW: number, ref: { frame: number; revealed: boolean; revealedAt: number; scaffoldAt: number; settled: boolean }): string[] {
|
|
204
64
|
if (sections.length === 0) return [];
|
|
205
65
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
66
|
+
// Colour comes from the theme's tokens, never from an escape we compose
|
|
67
|
+
// ourselves (ADR 0029) — the same two tokens pi's section rows use.
|
|
68
|
+
const heading = (t: string) => theme.fg("mdHeading", t);
|
|
69
|
+
const body = (t: string) => theme.fg("dim", t);
|
|
210
70
|
|
|
211
71
|
const itemAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
|
|
212
72
|
const labelAge = ref.revealed ? ref.frame - ref.scaffoldAt : 0;
|
|
213
73
|
|
|
214
|
-
// RGB endpoints for fade ramps (truecolor only)
|
|
74
|
+
// RGB endpoints for fade ramps (truecolor only): each row ramps to the colour
|
|
75
|
+
// pi paints it with at rest.
|
|
215
76
|
const fadeStartRgb: [number, number, number] = [20, 20, 20];
|
|
216
|
-
let
|
|
217
|
-
let
|
|
77
|
+
let headingRgb: [number, number, number] | undefined;
|
|
78
|
+
let bodyRgb: [number, number, number] | undefined;
|
|
218
79
|
const labelRamping = TRUECOLOR && ref.revealed && labelAge < RAMP_FRAMES + MAX_STAGGER;
|
|
219
80
|
const itemRamping = TRUECOLOR && ref.revealed && itemAge < RAMP_FRAMES + MAX_STAGGER;
|
|
220
81
|
if (labelRamping || itemRamping) {
|
|
221
|
-
|
|
222
|
-
|
|
82
|
+
headingRgb = extractRgb(theme.fg("mdHeading", " "));
|
|
83
|
+
bodyRgb = extractRgb(theme.fg("dim", " "));
|
|
223
84
|
}
|
|
224
85
|
|
|
225
86
|
const lines: string[] = [];
|
|
87
|
+
let firstSection = true;
|
|
226
88
|
|
|
227
89
|
for (let si = 0; si < sections.length; si++) {
|
|
228
90
|
const sec = sections[si]!;
|
|
229
91
|
if (sec.items.length === 0) continue;
|
|
230
92
|
|
|
231
|
-
|
|
93
|
+
// The blank row between sections, which is pi's `Spacer(1)`.
|
|
94
|
+
if (!firstSection) lines.push("");
|
|
95
|
+
firstSection = false;
|
|
232
96
|
|
|
233
|
-
// Label fade: near-invisible →
|
|
97
|
+
// Label fade: near-invisible → the heading colour (static when not revealed)
|
|
234
98
|
const secLabelAge = Math.max(0, labelAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
235
99
|
const wrapLabel = ref.revealed
|
|
236
|
-
? buildItemWrapper(secLabelAge, true, fadeStartRgb,
|
|
237
|
-
:
|
|
238
|
-
|
|
239
|
-
const paddedHeader = header + " ".repeat(Math.max(0, headerW - sec.name.length - 2));
|
|
100
|
+
? buildItemWrapper(secLabelAge, true, fadeStartRgb, headingRgb, heading)
|
|
101
|
+
: heading;
|
|
102
|
+
lines.push(truncateToWidth(wrapLabel(`[${sec.name}]`), maxW));
|
|
240
103
|
|
|
241
|
-
// Item fade: near-invisible →
|
|
104
|
+
// Item fade: near-invisible → the body colour
|
|
242
105
|
const secItemAge = Math.max(0, itemAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
243
|
-
const wrapItems = buildItemWrapper(secItemAge, ref.revealed, fadeStartRgb,
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
return wrapItems(raw);
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
let currentLine = "";
|
|
257
|
-
let currentStyled = "";
|
|
258
|
-
let firstLine = true;
|
|
259
|
-
|
|
106
|
+
const wrapItems = buildItemWrapper(secItemAge, ref.revealed, fadeStartRgb, bodyRgb, body);
|
|
107
|
+
|
|
108
|
+
// pi's compact body is one `dim` row of `, `-joined labels at a two-space
|
|
109
|
+
// indent — one colour over the whole row, the indent inside it (there is no
|
|
110
|
+
// token fainter than `dim` to set a prefix behind, and pi does not try).
|
|
111
|
+
// The row is broken across rows only when it will not fit the frame, which
|
|
112
|
+
// pi's own expander handles and a plain component does not.
|
|
113
|
+
const bodyW = Math.max(1, maxW - BODY_INDENT.length);
|
|
114
|
+
let rawRun = "";
|
|
260
115
|
for (const item of sec.items) {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
266
|
-
lines.push(clampLine(rawLine, maxW));
|
|
267
|
-
currentLine = item;
|
|
268
|
-
currentStyled = styleItem(item);
|
|
269
|
-
firstLine = false;
|
|
116
|
+
const candidate = rawRun ? `${rawRun}, ${item}` : item;
|
|
117
|
+
if (rawRun && visibleWidth(candidate) > bodyW) {
|
|
118
|
+
lines.push(truncateToWidth(wrapItems(BODY_INDENT + rawRun), maxW));
|
|
119
|
+
rawRun = item;
|
|
270
120
|
} else {
|
|
271
|
-
|
|
272
|
-
currentStyled = currentStyled ? currentStyled + " " + styleItem(item) : styleItem(item);
|
|
121
|
+
rawRun = candidate;
|
|
273
122
|
}
|
|
274
123
|
}
|
|
275
|
-
if (
|
|
276
|
-
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
277
|
-
lines.push(clampLine(rawLine, maxW));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (sec.name === "Version") {
|
|
281
|
-
lines.push("");
|
|
282
|
-
}
|
|
124
|
+
if (rawRun) lines.push(truncateToWidth(wrapItems(BODY_INDENT + rawRun), maxW));
|
|
283
125
|
}
|
|
284
126
|
|
|
285
127
|
return lines;
|
|
286
128
|
}
|
|
287
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The wrapper a row is drawn with: the ramped truecolor mix while the reveal is
|
|
132
|
+
* running, `atRest` once it is over (or where the terminal has no truecolor to
|
|
133
|
+
* ramp through).
|
|
134
|
+
*/
|
|
288
135
|
export function buildItemWrapper(
|
|
289
136
|
sectionAge: number,
|
|
290
137
|
revealed: boolean,
|
|
291
138
|
startRgb: [number, number, number] | undefined,
|
|
292
|
-
|
|
293
|
-
|
|
139
|
+
atRestRgb: [number, number, number] | undefined,
|
|
140
|
+
atRest: (t: string) => string,
|
|
294
141
|
): (text: string) => string {
|
|
295
142
|
if (!revealed) return (text) => text; // placeholders already styled
|
|
296
143
|
|
|
297
|
-
// No truecolor or ramp done →
|
|
298
|
-
if (!startRgb || !
|
|
144
|
+
// No truecolor or ramp done → the theme's own colour for the row
|
|
145
|
+
if (!startRgb || !atRestRgb || sectionAge >= RAMP_FRAMES) return atRest;
|
|
299
146
|
|
|
300
147
|
const t = Math.min(1, sectionAge / RAMP_FRAMES);
|
|
301
148
|
const eased = 1 - (1 - t) * (1 - t);
|
|
302
|
-
const r = lerp(startRgb[0],
|
|
303
|
-
const g = lerp(startRgb[1],
|
|
304
|
-
const b = lerp(startRgb[2],
|
|
149
|
+
const r = lerp(startRgb[0], atRestRgb[0], eased);
|
|
150
|
+
const g = lerp(startRgb[1], atRestRgb[1], eased);
|
|
151
|
+
const b = lerp(startRgb[2], atRestRgb[2], eased);
|
|
305
152
|
return (text) => rgb(r, g, b, text);
|
|
306
153
|
}
|