gentle-pi 0.2.3 → 0.2.5

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,5 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ statSync,
9
+ watch,
10
+ writeFileSync,
11
+ } from "node:fs";
3
12
  import { homedir } from "node:os";
4
13
  import { basename, join, relative } from "node:path";
5
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -11,7 +20,10 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
11
20
  const EXCLUDE_PREFIXES = ["sdd-"];
12
21
  const ATL_IGNORE_ENTRY = ".atl/";
13
22
  const WATCH_DEBOUNCE_MS = 500;
14
- const REGISTRY_SCHEMA_VERSION = 2;
23
+ const REGISTRY_SCHEMA_VERSION = 3;
24
+ const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
25
+ const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
26
+ ".pi/extensions/skill-registry.ts.disabled";
15
27
 
16
28
  interface SkillEntry {
17
29
  name: string;
@@ -188,7 +200,7 @@ function renderRegistry(cwd: string, sources: string[], entries: SkillEntry[]):
188
200
  const lines: string[] = [];
189
201
  lines.push(`# Skill Registry — ${projectName}`);
190
202
  lines.push("");
191
- lines.push("<!-- Auto-generated by .pi/extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->");
203
+ lines.push("<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->");
192
204
  lines.push("");
193
205
  lines.push(`Last updated: ${today}`);
194
206
  lines.push("");
@@ -243,6 +255,47 @@ function ensureAtlIgnored(cwd: string): void {
243
255
  writeFileSync(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
244
256
  }
245
257
 
258
+ function isGeneratedLegacyProjectRegistry(source: string): boolean {
259
+ return (
260
+ source.includes("Auto-generated by .pi/extensions/skill-registry.ts") &&
261
+ source.includes("const REGISTRY_REL_PATH = \".atl/skill-registry.md\"") &&
262
+ source.includes("function projectSkillDirs(cwd: string): string[]") &&
263
+ source.includes("function regenerateRegistry(cwd: string, force: boolean)") &&
264
+ (!source.includes('join(cwd, "skills")') ||
265
+ source.includes("const dirs = [...userSkillDirs(), ...projectSkillDirs(cwd)]") ||
266
+ source.includes("if (rules.length === 0) return undefined"))
267
+ );
268
+ }
269
+
270
+ function nextLegacyDisabledPath(cwd: string): string {
271
+ const base = join(cwd, LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH);
272
+ if (!existsSync(base)) return base;
273
+ for (let i = 1; i < 100; i++) {
274
+ const candidate = `${base}.${i}`;
275
+ if (!existsSync(candidate)) return candidate;
276
+ }
277
+ return `${base}.${Date.now()}`;
278
+ }
279
+
280
+ function quarantineLegacyProjectRegistry(cwd: string): boolean {
281
+ const legacyPath = join(cwd, LEGACY_PROJECT_REGISTRY_REL_PATH);
282
+ if (!existsSync(legacyPath)) return false;
283
+ let source = "";
284
+ try {
285
+ source = readFileSync(legacyPath, "utf8");
286
+ } catch {
287
+ return false;
288
+ }
289
+ if (!isGeneratedLegacyProjectRegistry(source)) return false;
290
+ const disabledPath = nextLegacyDisabledPath(cwd);
291
+ try {
292
+ renameSync(legacyPath, disabledPath);
293
+ return true;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
246
299
  function regenerateRegistry(cwd: string, force: boolean): RegenResult {
247
300
  const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
248
301
  const files = existingDirs.flatMap(findSkillFiles).sort();
@@ -308,13 +361,29 @@ export default function (pi: ExtensionAPI) {
308
361
  pi.on("session_start", async (_event, ctx) => {
309
362
  try {
310
363
  ensureAtlIgnored(ctx.cwd);
311
- const result = regenerateRegistry(ctx.cwd, false);
364
+ const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
365
+ const result = regenerateRegistry(ctx.cwd, quarantinedLegacy);
312
366
  if (result.regenerated && ctx.hasUI) {
313
367
  ctx.ui.notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
314
368
  }
369
+ if (quarantinedLegacy && ctx.hasUI) {
370
+ ctx.ui.notify(
371
+ "Disabled stale project-local skill registry extension; using package registry with project skills first.",
372
+ "warning",
373
+ );
374
+ }
315
375
  startSkillRegistryWatcher(ctx.cwd, (message) => {
316
376
  if (ctx.hasUI) ctx.ui.notify(message, "info");
317
377
  });
378
+ if (quarantinedLegacy) {
379
+ setTimeout(() => {
380
+ try {
381
+ regenerateRegistry(ctx.cwd, true);
382
+ } catch {
383
+ // Best-effort same-session self-heal in case the stale extension already ran.
384
+ }
385
+ }, WATCH_DEBOUNCE_MS);
386
+ }
318
387
  } catch (error) {
319
388
  if (ctx.hasUI) {
320
389
  const message = error instanceof Error ? error.message : String(error);
@@ -63,6 +63,9 @@ function padLines(lines: string[]): { lines: string[]; width: number } {
63
63
 
64
64
  type CellType =
65
65
  | "banner"
66
+ | "logo-tip"
67
+ | "logo-fresh"
68
+ | "logo-ink"
66
69
  | "rose"
67
70
  | "label"
68
71
  | "value"
@@ -70,6 +73,280 @@ type CellType =
70
73
  | "accent"
71
74
  | "none";
72
75
  type LayoutCell = { char: string; type: CellType };
76
+ type LogoCellType = Extract<
77
+ CellType,
78
+ "banner" | "logo-tip" | "logo-fresh" | "logo-ink"
79
+ >;
80
+
81
+ const LOGO_CELL_TYPES: ReadonlySet<CellType> = new Set<CellType>([
82
+ "banner",
83
+ "logo-tip",
84
+ "logo-fresh",
85
+ "logo-ink",
86
+ ]);
87
+
88
+ function isLogoCellType(type: CellType): type is LogoCellType {
89
+ return LOGO_CELL_TYPES.has(type);
90
+ }
91
+
92
+ type Span = { start: number; end: number };
93
+
94
+ function computeLogoBounds(lines: string[]): Span {
95
+ let start = Number.POSITIVE_INFINITY;
96
+ let end = Number.NEGATIVE_INFINITY;
97
+ for (const line of lines) {
98
+ for (let i = 0; i < line.length; i++) {
99
+ if (line[i] !== " ") {
100
+ if (i < start) start = i;
101
+ if (i > end) end = i;
102
+ }
103
+ }
104
+ }
105
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
106
+ return { start: 0, end: 0 };
107
+ }
108
+ return { start, end };
109
+ }
110
+
111
+ function buildLetterSpans(bounds: Span, weights: number[]): Span[] {
112
+ const spanWidth = Math.max(1, bounds.end - bounds.start + 1);
113
+ const total = weights.reduce((a, b) => a + b, 0);
114
+ let cursor = bounds.start;
115
+ return weights.map((w, i) => {
116
+ const remaining = bounds.end - cursor + 1;
117
+ const raw = Math.max(1, Math.round((w / total) * spanWidth));
118
+ const width =
119
+ i === weights.length - 1
120
+ ? remaining
121
+ : Math.min(raw, remaining - (weights.length - i - 1));
122
+ const s = cursor;
123
+ const e = s + width - 1;
124
+ cursor = e + 1;
125
+ return { start: s, end: e };
126
+ });
127
+ }
128
+
129
+ const LOGO_BOUNDS = computeLogoBounds(TEXT_LOGO);
130
+ const LETTER_WEIGHTS = [14, 10, 11, 10, 9, 11, 6, 13, 12]; // G E N T L E - P I
131
+ const LETTER_SPANS = buildLetterSpans(LOGO_BOUNDS, LETTER_WEIGHTS);
132
+
133
+ function letterIndexAtX(x: number): number {
134
+ for (let i = 0; i < LETTER_SPANS.length; i++) {
135
+ const s = LETTER_SPANS[i];
136
+ if (x >= s.start && x <= s.end) return i;
137
+ }
138
+ if (x < LETTER_SPANS[0].start) return 0;
139
+ return LETTER_SPANS.length - 1;
140
+ }
141
+
142
+ type Point = { x: number; y: number };
143
+
144
+ function pointKey(x: number, y: number): string {
145
+ return `${x}:${y}`;
146
+ }
147
+
148
+ function buildLetterStrokeMap(letterIdx: number): { orderMap: Map<string, number>; maxOrder: number } {
149
+ const span = LETTER_SPANS[letterIdx];
150
+ const points: Point[] = [];
151
+ const pointSet = new Set<string>();
152
+
153
+ for (let y = 0; y < TEXT_LOGO.length; y++) {
154
+ const line = TEXT_LOGO[y] ?? "";
155
+ for (let x = span.start; x <= Math.min(span.end, line.length - 1); x++) {
156
+ if (line[x] !== " ") {
157
+ points.push({ x, y });
158
+ pointSet.add(pointKey(x, y));
159
+ }
160
+ }
161
+ }
162
+
163
+ const neighbors8 = [
164
+ [-1, -1], [0, -1], [1, -1],
165
+ [-1, 0], [1, 0],
166
+ [-1, 1], [0, 1], [1, 1],
167
+ ] as const;
168
+
169
+ const visited = new Set<string>();
170
+ const components: Point[][] = [];
171
+
172
+ for (const p of points) {
173
+ const k = pointKey(p.x, p.y);
174
+ if (visited.has(k)) continue;
175
+
176
+ const stack = [p];
177
+ const comp: Point[] = [];
178
+ visited.add(k);
179
+
180
+ while (stack.length > 0) {
181
+ const cur = stack.pop()!;
182
+ comp.push(cur);
183
+ for (const [dx, dy] of neighbors8) {
184
+ const nk = pointKey(cur.x + dx, cur.y + dy);
185
+ if (!visited.has(nk) && pointSet.has(nk)) {
186
+ visited.add(nk);
187
+ stack.push({ x: cur.x + dx, y: cur.y + dy });
188
+ }
189
+ }
190
+ }
191
+
192
+ components.push(comp);
193
+ }
194
+
195
+ components.sort((a, b) => {
196
+ const ax = Math.min(...a.map((p) => p.x));
197
+ const bx = Math.min(...b.map((p) => p.x));
198
+ if (ax !== bx) return ax - bx;
199
+ const ay = Math.min(...a.map((p) => p.y));
200
+ const by = Math.min(...b.map((p) => p.y));
201
+ return ay - by;
202
+ });
203
+
204
+ const orderMap = new Map<string, number>();
205
+ let order = 0;
206
+
207
+ for (const comp of components) {
208
+ const compSet = new Set(comp.map((p) => pointKey(p.x, p.y)));
209
+ const compMap = new Map(comp.map((p) => [pointKey(p.x, p.y), p]));
210
+
211
+ let current = comp.reduce((best, p) =>
212
+ p.x < best.x || (p.x === best.x && p.y < best.y) ? p : best,
213
+ );
214
+
215
+ let dirX = 1;
216
+ let dirY = 0;
217
+
218
+ while (compSet.size > 0) {
219
+ const ck = pointKey(current.x, current.y);
220
+ if (compSet.has(ck)) {
221
+ compSet.delete(ck);
222
+ orderMap.set(ck, order++);
223
+ }
224
+ if (compSet.size === 0) break;
225
+
226
+ const candidates: Point[] = [];
227
+ for (let dy = -2; dy <= 2; dy++) {
228
+ for (let dx = -2; dx <= 2; dx++) {
229
+ if (dx === 0 && dy === 0) continue;
230
+ const nk = pointKey(current.x + dx, current.y + dy);
231
+ if (compSet.has(nk)) {
232
+ const point = compMap.get(nk);
233
+ if (point) candidates.push(point);
234
+ }
235
+ }
236
+ }
237
+
238
+ let next: Point | null = null;
239
+ if (candidates.length > 0) {
240
+ candidates.sort((a, b) => {
241
+ const adx = a.x - current.x;
242
+ const ady = a.y - current.y;
243
+ const bdx = b.x - current.x;
244
+ const bdy = b.y - current.y;
245
+
246
+ const aDist = Math.hypot(adx, ady);
247
+ const bDist = Math.hypot(bdx, bdy);
248
+ const aTurn = Math.abs(adx * dirY - ady * dirX);
249
+ const bTurn = Math.abs(bdx * dirY - bdy * dirX);
250
+
251
+ const aScore = aDist * 3.8 + aTurn * 1.3 + Math.abs(ady) * 0.12;
252
+ const bScore = bDist * 3.8 + bTurn * 1.3 + Math.abs(bdy) * 0.12;
253
+ return aScore - bScore;
254
+ });
255
+ next = candidates[0];
256
+ } else {
257
+ let best: Point | null = null;
258
+ let bestScore = Number.POSITIVE_INFINITY;
259
+ for (const k of compSet) {
260
+ const p = compMap.get(k);
261
+ if (!p) continue;
262
+ const dx = p.x - current.x;
263
+ const dy = p.y - current.y;
264
+ const score = Math.hypot(dx, dy) + Math.abs(dy) * 0.16;
265
+ if (score < bestScore) {
266
+ bestScore = score;
267
+ best = p;
268
+ }
269
+ }
270
+ next = best;
271
+ }
272
+
273
+ if (!next) break;
274
+ dirX = next.x - current.x;
275
+ dirY = next.y - current.y;
276
+ current = next;
277
+ }
278
+ }
279
+
280
+ return { orderMap, maxOrder: Math.max(1, order - 1) };
281
+ }
282
+
283
+ const LETTER_STROKES = LETTER_SPANS.map((_, i) => buildLetterStrokeMap(i));
284
+ const WRITING_START_TICK = 6;
285
+ const LETTER_TICKS = LETTER_STROKES.map((s) =>
286
+ Math.max(5, Math.ceil(((s.maxOrder + 8) / 11) * 0.48)),
287
+ );
288
+ const LETTER_START_TICKS = LETTER_TICKS.map((_, i) =>
289
+ WRITING_START_TICK + LETTER_TICKS.slice(0, i).reduce((a, b) => a + b, 0),
290
+ );
291
+ const WRITING_END_TICK = WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
292
+
293
+ function buildPenLogoLine(
294
+ line: string,
295
+ rowIdx: number,
296
+ _totalRows: number,
297
+ tick: number,
298
+ ): LayoutCell[] {
299
+ const out: LayoutCell[] = [];
300
+
301
+ for (let x = 0; x < line.length; x++) {
302
+ const ch = line[x] ?? " ";
303
+ if (ch === " ") {
304
+ out.push({ char: " ", type: "none" });
305
+ continue;
306
+ }
307
+
308
+ const letterIdx = letterIndexAtX(x);
309
+ const stroke = LETTER_STROKES[letterIdx];
310
+ const startTick = LETTER_START_TICKS[letterIdx];
311
+ const duration = LETTER_TICKS[letterIdx];
312
+ const progress = (tick - startTick) / Math.max(1, duration);
313
+
314
+ if (progress < 0) {
315
+ out.push({ char: " ", type: "none" });
316
+ continue;
317
+ }
318
+
319
+ const head = progress * (stroke.maxOrder + 7);
320
+ const rawOrder = stroke.orderMap.get(pointKey(x, rowIdx));
321
+ if (rawOrder === undefined) {
322
+ out.push({ char: " ", type: "none" });
323
+ continue;
324
+ }
325
+
326
+ let order = rawOrder;
327
+ // v1: ajuste SOLO para la primera letra (G), con más curvatura caligráfica.
328
+ if (letterIdx === 0) {
329
+ const s = LETTER_SPANS[0];
330
+ const w = Math.max(1, s.end - s.start + 1);
331
+ const localX = x - s.start;
332
+ const curveBias =
333
+ Math.sin((localX / w) * Math.PI * 1.35 + rowIdx * 0.26) * 2.2 +
334
+ Math.cos((localX / w) * Math.PI * 0.72 - rowIdx * 0.20) * 1.3;
335
+ order = rawOrder + curveBias;
336
+ }
337
+
338
+ if (head < order) {
339
+ out.push({ char: " ", type: "none" });
340
+ continue;
341
+ }
342
+
343
+ const age = head - order;
344
+ if (age < 1.2) out.push({ char: ch, type: "logo-tip" });
345
+ else if (age < 4.9) out.push({ char: ch, type: "logo-fresh" });
346
+ else out.push({ char: ch, type: "logo-ink" });
347
+ }
348
+ return out;
349
+ }
73
350
 
74
351
  class LayoutBuilder {
75
352
  lines: LayoutCell[][] = [];
@@ -86,7 +363,7 @@ class LayoutBuilder {
86
363
  center(width: number) {
87
364
  const row = this.lines[this.lines.length - 1];
88
365
  const pad = Math.max(0, Math.floor((width - row.length) / 2));
89
- const prefix = Array.from({ length: pad }, () => ({
366
+ const prefix: LayoutCell[] = Array.from({ length: pad }, () => ({
90
367
  char: " ",
91
368
  type: "none" as const,
92
369
  }));
@@ -106,29 +383,6 @@ export default function (pi: ExtensionAPI) {
106
383
 
107
384
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
108
385
 
109
- let closeIntro: (() => void) | null = null;
110
- const closeIntroSafely = () => {
111
- if (!closeIntro) return;
112
- const fn = closeIntro;
113
- closeIntro = null;
114
- try {
115
- fn();
116
- } catch {}
117
- };
118
-
119
- void ctx.ui
120
- .custom((_tui, _theme, _keybindings, done) => {
121
- closeIntro = () => done(undefined);
122
- return {
123
- render: () => [""],
124
- invalidate: () => {},
125
- handleInput: () => {},
126
- };
127
- })
128
- .catch(() => {
129
- closeIntro = null;
130
- });
131
-
132
386
  const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
133
387
  const logoBase = padLines(TEXT_LOGO);
134
388
 
@@ -196,12 +450,11 @@ export default function (pi: ExtensionAPI) {
196
450
 
197
451
  state.timer = setInterval(() => {
198
452
  tick++;
199
- if (tick > 90) {
453
+ if (tick > WRITING_END_TICK + 22) {
200
454
  if (state.timer) {
201
455
  clearInterval(state.timer);
202
456
  state.timer = null;
203
457
  }
204
- closeIntroSafely();
205
458
  return;
206
459
  }
207
460
  try {
@@ -212,15 +465,15 @@ export default function (pi: ExtensionAPI) {
212
465
  state.timer = null;
213
466
  }
214
467
  }
215
- }, 50);
468
+ }, 25);
216
469
 
217
470
  return {
218
471
  render(width: number): string[] {
219
- const flashStartTick = 16;
220
- const roseOpacity = Math.min(1, tick / 16);
472
+ const flashStartTick = 10;
473
+ const roseOpacity = Math.min(1, tick / 10);
221
474
  const flashPhase =
222
475
  tick >= flashStartTick
223
- ? Math.max(0, 1 - (tick - flashStartTick) / 20)
476
+ ? Math.max(0, 1 - (tick - flashStartTick) / 12)
224
477
  : 0;
225
478
  const frame = Math.floor(tick / 2);
226
479
 
@@ -262,16 +515,35 @@ export default function (pi: ExtensionAPI) {
262
515
  b.addRow();
263
516
  b.add("rose", roseLine);
264
517
  b.add("none", " ");
265
- b.add("banner", logoLine);
518
+ if (logoI >= 0 && logoI < logoBase.lines.length) {
519
+ b.lines[b.lines.length - 1].push(
520
+ ...buildPenLogoLine(
521
+ logoLine,
522
+ logoI,
523
+ logoBase.lines.length,
524
+ tick,
525
+ ),
526
+ );
527
+ } else {
528
+ b.add("none", " ".repeat(logoBase.width));
529
+ }
266
530
  b.center(width);
267
531
  }
268
532
  } else {
269
533
  const showBanner = width >= logoBase.width + 2;
270
534
  const showRose = width >= roseBase.width + 2;
271
535
  if (showBanner) {
272
- for (const logoLine of logoBase.lines) {
536
+ for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
537
+ const logoLine = logoBase.lines[logoI];
273
538
  b.addRow();
274
- b.add("banner", logoLine);
539
+ b.lines[b.lines.length - 1].push(
540
+ ...buildPenLogoLine(
541
+ logoLine,
542
+ logoI,
543
+ logoBase.lines.length,
544
+ tick,
545
+ ),
546
+ );
275
547
  b.center(width);
276
548
  }
277
549
  if (showRose) {
@@ -376,9 +648,46 @@ export default function (pi: ExtensionAPI) {
376
648
  const out: string[] = [];
377
649
  const layout = b.lines;
378
650
 
651
+ const logoRows = layout
652
+ .map((row, idx) => ({
653
+ idx,
654
+ hasLogo: (row || []).some((c) => isLogoCellType(c.type)),
655
+ }))
656
+ .filter((r) => r.hasLogo)
657
+ .map((r) => r.idx);
658
+ const sparkleY =
659
+ logoRows.length > 0
660
+ ? logoRows[Math.floor(logoRows.length / 2)]
661
+ : -1;
662
+ const logoLastX = Math.max(
663
+ -1,
664
+ ...layout.map((row) => {
665
+ let last = -1;
666
+ for (let i = 0; i < (row || []).length; i++) {
667
+ const cell = row?.[i];
668
+ if (cell && isLogoCellType(cell.type) && cell.char !== " ") {
669
+ last = i;
670
+ }
671
+ }
672
+ return last;
673
+ }),
674
+ );
675
+
676
+ const glintStartTick = WRITING_END_TICK + 3;
677
+ const glintEndTick = WRITING_END_TICK + 12;
678
+ const glintActive = tick >= glintStartTick && tick <= glintEndTick;
679
+ const glintHead =
680
+ ((tick - glintStartTick) /
681
+ Math.max(1, glintEndTick - glintStartTick)) *
682
+ (LOGO_BOUNDS.end - LOGO_BOUNDS.start + 1);
683
+ const sparkleActive =
684
+ tick >= WRITING_END_TICK + 13 && tick <= WRITING_END_TICK + 20;
685
+
379
686
  for (let y = 0; y < layout.length; y++) {
380
687
  const row = layout[y] || [];
381
- const firstBannerX = row.findIndex((c) => c.type === "banner");
688
+ const firstLogoX = row.findIndex(
689
+ (c) => isLogoCellType(c.type) && c.char !== " ",
690
+ );
382
691
  let line = "";
383
692
 
384
693
  for (let x = 0; x < row.length; x++) {
@@ -391,7 +700,7 @@ export default function (pi: ExtensionAPI) {
391
700
  if (cell.type === "rose") {
392
701
  const pulse = 0.9 + Math.sin((x + y + frame) * 0.08) * 0.1;
393
702
  const k = Math.max(0.01, roseOpacity * pulse);
394
- const f = Math.pow(flashPhase, 0.4);
703
+ const f = flashPhase ** 0.4;
395
704
 
396
705
  const rBase = Math.floor(255 * k);
397
706
  const gBase = Math.floor(118 * k);
@@ -408,20 +717,37 @@ export default function (pi: ExtensionAPI) {
408
717
  continue;
409
718
  }
410
719
 
411
- if (cell.type === "banner") {
412
- if (cell.char === "▒") {
413
- line += rgb(95, 30, 60, cell.char);
720
+ if (isLogoCellType(cell.type)) {
721
+ const localLogoX = firstLogoX >= 0 ? x - firstLogoX : x;
722
+ const glintOnCell =
723
+ glintActive &&
724
+ localLogoX >= glintHead - 2 &&
725
+ localLogoX <= glintHead + 1;
726
+ const sparkleOnCell =
727
+ sparkleActive &&
728
+ y === sparkleY &&
729
+ (x === logoLastX || x === logoLastX - 1);
730
+
731
+ if (sparkleOnCell) {
732
+ line += `\x1b[1m` + rgb(255, 255, 255, "✦") + `\x1b[22m`;
414
733
  continue;
415
734
  }
416
- const localX = firstBannerX >= 0 ? x - firstBannerX : x;
417
- const sweep = Math.floor((tick - 16) * 2.2);
418
- const isFlashing =
419
- tick >= 16 && localX >= sweep - 4 && localX <= sweep + 2;
420
735
 
421
- if (isFlashing) {
422
- line += `\x1b[1m\x1b[38;2;255;255;255m${cell.char}\x1b[0m`;
736
+ if (glintOnCell) {
737
+ line += `\x1b[1m` + rgb(255, 245, 252, cell.char) + `\x1b[22m`;
738
+ continue;
739
+ }
740
+
741
+ if (cell.type === "logo-tip") {
742
+ line += `\x1b[1m` + rgb(255, 205, 238, cell.char) + `\x1b[22m`;
743
+ } else if (cell.type === "logo-fresh") {
744
+ line += cell.char === "▒"
745
+ ? rgb(110, 36, 70, cell.char)
746
+ : rgb(255, 138, 206, cell.char);
423
747
  } else {
424
- line += rgb(255, 120, 198, cell.char);
748
+ line += cell.char === "▒"
749
+ ? rgb(95, 30, 60, cell.char)
750
+ : rgb(255, 120, 198, cell.char);
425
751
  }
426
752
  continue;
427
753
  }
@@ -454,7 +780,6 @@ export default function (pi: ExtensionAPI) {
454
780
  clearInterval(state.timer);
455
781
  state.timer = null;
456
782
  }
457
- closeIntroSafely();
458
783
  },
459
784
  };
460
785
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",