@pi-archimedes/ui 2.10.4 → 2.11.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.
@@ -1,22 +1,24 @@
1
- import { clampLine, stripAnsi } from "@pi-archimedes/core/text";
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
- 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";
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: SectionKey;
16
+ name: string;
15
17
  items: string[];
16
18
  }
17
19
 
18
20
  interface RenderSection {
19
- name: RenderSectionKey;
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
- // ── 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
- }
32
+ /** pi's compact body indent: `theme.fg("dim", ` ${labels.join(", ")}`)`. */
33
+ const BODY_INDENT = " ";
79
34
 
80
- const itemNames = extractNames(trimmed, sectionName);
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
- * Extract item name(s) from a line.
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
- * Collapsed section bodies are a single comma-joined line ("a, b, c") —
92
- * split it so each name is its own item and the listing can wrap, instead
93
- * of one clamped line ending in "...". Expanded bodies are per-item path
94
- * lines; names never contain ", ", so splitting is safe.
95
- */
96
- function extractNames(line: string, sectionName: SectionKey): string[] {
97
- const parts = line.includes(", ")
98
- ? line.split(", ").map(p => p.trim()).filter(Boolean)
99
- : [line];
100
- const out: string[] = [];
101
- for (const part of parts) {
102
- const name = extractName(part, sectionName);
103
- if (name) out.push(name);
104
- }
105
- return out;
106
- }
107
-
108
- /** Check if a line should be skipped during section parsing. */
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
- const dim = (t: string) => theme.fg("dim", t);
207
- const muted = (t: string) => theme.fg("muted", t);
208
-
209
- const headerW = Math.max(...sections.map(s => s.name.length + 2)) + 2;
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 dimRgb: [number, number, number] | undefined;
217
- let mutedRgb: [number, number, number] | undefined;
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
- dimRgb = extractRgb(theme.fg("dim", " "));
222
- mutedRgb = extractRgb(theme.fg("muted", " "));
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
- const availableW = maxW - headerW - 1;
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 → dim (static dim when not revealed)
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, dimRgb, dim)
237
- : dim;
238
- const header = wrapLabel(`[${sec.name}]`);
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 → muted
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, mutedRgb, muted);
244
-
245
- // Style prefix (npm:/git:) dimmer than the name
246
- const styleItem = (raw: string): string => {
247
- const prefixMatch = raw.match(/^(npm:|git:)/);
248
- if (prefixMatch) {
249
- const pfx = prefixMatch[1]!;
250
- const name = raw.slice(pfx.length);
251
- return wrapLabel(pfx) + wrapItems(name);
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 itemW = visibleWidth(item);
262
- const currentW = visibleWidth(currentLine);
263
-
264
- if (currentLine && currentW + 2 + itemW > availableW) {
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
- currentLine = currentLine ? currentLine + " " + item : item;
272
- currentStyled = currentStyled ? currentStyled + " " + styleItem(item) : styleItem(item);
121
+ rawRun = candidate;
273
122
  }
274
123
  }
275
- if (currentLine) {
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
- mutedRgb: [number, number, number] | undefined,
293
- muted: (t: string) => string,
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 → static muted
298
- if (!startRgb || !mutedRgb || sectionAge >= RAMP_FRAMES) return muted;
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], mutedRgb[0], eased);
303
- const g = lerp(startRgb[1], mutedRgb[1], eased);
304
- const b = lerp(startRgb[2], mutedRgb[2], eased);
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
  }