@tenchi4u/pi-cc-header 1.0.1 → 1.0.4

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.
Files changed (3) hide show
  1. package/README.md +115 -115
  2. package/extensions/pi-cc-header.ts +1471 -1445
  3. package/package.json +54 -54
@@ -1,1452 +1,1478 @@
1
- import {
2
- CONFIG_DIR_NAME,
3
- VERSION,
4
- getAgentDir,
5
- type ExtensionAPI,
6
- type ExtensionContext,
7
- } from "@earendil-works/pi-coding-agent";
8
- import type { Component, TUI } from "@earendil-works/pi-tui";
9
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
- import {
11
- readFileSync,
12
- writeFileSync,
13
- readdirSync,
14
- existsSync,
15
- copyFileSync,
16
- mkdirSync,
17
- } from "node:fs";
18
- import { dirname, join } from "node:path";
19
- import { homedir } from "node:os";
20
- import { fileURLToPath } from "node:url";
21
-
22
- /* ── Types ── */
23
- interface CCHeaderConfig extends Record<string, any> {
24
- readOnlyConfig?: boolean;
25
- }
26
-
27
- interface SettingsFile {
28
- ccHeader?: CCHeaderConfig;
29
- quietStartup?: boolean;
30
- clearOnStart?: boolean;
31
- packages?: string[];
32
- [key: string]: any;
33
- }
34
-
35
- interface CCHeaderState {
36
- logoColorKey: string;
37
- versionColored: number; // 0=off 1=Pi only 2=Pi+ver
38
- gradientOn: boolean;
39
- stripeEnabled: boolean;
40
- showPkgSkills: boolean;
41
- logoInterval: number;
42
- slogan: string;
43
- sloganOn: boolean;
44
- sloganColor: boolean;
45
- sloganColorKey: string;
46
- disabled: boolean;
47
- showModelLine: boolean; // NEW: toggle model/thinking line
48
- customLogoLines: string[] | null;
49
- }
50
-
51
- /* ── Constants ── */
52
- const SPEEDS = [25, 50, 75, 100] as const;
53
- const LOGO_COLS = 8;
54
- const LOGO_ROWS = 7;
55
- const LOGO_PIXEL_WIDTH = 14;
56
- export const MAX_SLOGAN_LENGTH = 250;
57
- const COLOR_NAMES: Record<string, string> = {
58
- a: "anthropic",
59
- c: "clawd",
60
- r: "red",
61
- o: "orange",
62
- y: "yellow",
63
- g: "green",
64
- w: "white",
65
- b: "blue",
66
- p: "purple",
67
- };
68
- const DEFAULT_STATE: CCHeaderState = {
69
- logoColorKey: "c",
70
- versionColored: 1,
71
- gradientOn: true,
72
- stripeEnabled: true,
73
- showPkgSkills: false,
74
- logoInterval: SPEEDS[1],
75
- slogan: "Code something that makes you proud",
76
- sloganOn: true,
77
- sloganColor: true,
78
- sloganColorKey: "c",
79
- quoteMode: false,
80
- disabled: false,
81
- showModelLine: true,
82
- customLogoLines: null,
83
- };
84
- const CMAP: Record<string, string> = {
85
- a: "38;2;217;119;87",
86
- r: "31",
87
- o: "38;5;208",
88
- y: "38;5;226",
89
- g: "38;2;20;180;20",
90
- w: "38;5;15",
91
- b: "38;2;40;130;220",
92
- p: "38;5;129",
93
- c: "38;2;251;73;52",
94
- };
95
- const GMAP: Record<string, string[]> = {
96
- a: ["38;2;217;119;87", "38;2;200;100;70", "38;2;170;80;55", "38;2;130;60;40"],
97
- r: ["38;2;255;80;80", "38;2;220;40;40", "38;2;180;20;20", "38;2;140;10;10"],
98
- o: [
99
- "38;2;255;170;50",
100
- "38;2;230;140;30",
101
- "38;2;200;110;20",
102
- "38;2;160;80;10",
103
- ],
104
- y: [
105
- "38;2;255;255;80",
106
- "38;2;230;230;40",
107
- "38;2;200;200;20",
108
- "38;2;160;160;10",
109
- ],
110
- g: ["38;2;80;255;80", "38;2;40;220;40", "38;2;20;180;20", "38;2;10;140;10"],
111
- w: [
112
- "38;2;230;230;210",
113
- "38;2;190;190;170",
114
- "38;2;140;140;120",
115
- "38;2;100;100;85",
116
- ],
117
- b: [
118
- "38;2;100;180;255",
119
- "38;2;70;160;245",
120
- "38;2;40;130;220",
121
- "38;2;20;100;195",
122
- ],
123
- p: [
124
- "38;2;200;100;255",
125
- "38;2;170;70;230",
126
- "38;2;140;40;200",
127
- "38;2;110;20;160",
128
- ],
129
- c: ["38;2;251;73;52", "38;2;220;60;40", "38;2;190;45;30", "38;2;155;30;20"],
130
- };
131
- const GRADIENT_LEVEL: Record<string, number> = {
132
- l1: 0,
133
- l2: 1,
134
- l3: 2,
135
- l4: 3,
136
- s1: 0,
137
- s2: 1,
138
- s3: 2,
139
- s4: 3,
140
- };
141
-
142
- /* ── Runtime state ── */
143
- let state: CCHeaderState = { ...DEFAULT_STATE };
144
- let framesDirty = true;
145
-
146
- /* ── Quotes ── */
147
- interface Quote {
148
- quote: string;
149
- author: string;
150
- }
151
-
152
- let quotesCache: Quote[] | null = null;
153
-
154
- function loadQuotes(ctx: ExtensionContext): Quote[] {
155
- if (quotesCache) return quotesCache;
156
-
157
- const projectRoot = ctx.cwd;
158
- const quotesPath = join(projectRoot, "quotes.json");
159
-
160
- if (!existsSync(quotesPath)) {
161
- // Try the package directory as fallback
162
- const __filename = fileURLToPath(import.meta.url);
163
- const pkgDir = dirname(dirname(__filename));
164
- const fallbackPath = join(pkgDir, "quotes.json");
165
- if (existsSync(fallbackPath)) {
166
- try {
167
- quotesCache = JSON.parse(readFileSync(fallbackPath, "utf-8"));
168
- return quotesCache!;
169
- } catch {
170
- return [];
171
- }
172
- }
173
- return [];
174
- }
175
-
176
- try {
177
- quotesCache = JSON.parse(readFileSync(quotesPath, "utf-8"));
178
- return quotesCache!;
179
- } catch {
180
- return [];
181
- }
182
- }
183
-
184
- export function formatQuote(quote: string, author: string, punctBonus = 25): string {
185
- const words = quote.trim().split(/\s+/);
186
- if (words.length <= 10) {
187
- return `"${quote}"\n${author}`;
188
- }
189
-
190
- let bestI = 1;
191
- let bestScore = Infinity;
192
- for (let i = 1; i < words.length; i++) {
193
- const p1 = words.slice(0, i).join(" ");
194
- const p2 = words.slice(i).join(" ");
195
- const lenDiff = Math.abs(p1.length - p2.length);
196
- const lastChar = p1[p1.length - 1];
197
- const hasPunct = [":", ".", ",", ";", "-", "—"].includes(lastChar);
198
- const score = lenDiff - (hasPunct ? punctBonus : 0);
199
- if (score < bestScore) {
200
- bestScore = score;
201
- bestI = i;
202
- }
203
- }
204
-
205
- const line1 = `"${words.slice(0, bestI).join(" ")}`;
206
- const line2 = `${words.slice(bestI).join(" ")}"`;
207
- return `${line1}\n${line2}\n${author}`;
208
- }
209
-
210
- export function getRandomQuote(ctx: ExtensionContext): string | null {
211
- const quotes = loadQuotes(ctx);
212
- if (quotes.length === 0) return null;
213
- const random = quotes[Math.floor(Math.random() * quotes.length)];
214
- return formatQuote(random.quote, random.author);
215
- }
216
-
217
- /* ── Pi logo animation ── */
218
- type LogoColor =
219
- | "panel"
220
- | "cyan"
221
- | "red"
222
- | "green"
223
- | "orange"
224
- | "flash"
225
- | "logo"
226
- | "logoStripe"
227
- | "white"
228
- | "l1"
229
- | "l2"
230
- | "l3"
231
- | "l4"
232
- | "s1"
233
- | "s2"
234
- | "s3"
235
- | "s4";
236
- type LogoPhase = "left" | "top" | "right" | "none";
237
- type LogoFrame = {
238
- phase: number;
239
- active: LogoPhase;
240
- ax: number;
241
- ay: number;
242
- flash: boolean;
243
- white: boolean;
244
- };
245
-
246
- const LOGO_FRAMES: LogoFrame[] = [
247
- ...Array.from({ length: 4 }, (_, ay) => ({
248
- phase: 0,
249
- active: "left" as const,
250
- ax: 2,
251
- ay,
252
- flash: false,
253
- white: false,
254
- })),
255
- ...Array.from({ length: 3 }, (_, ay) => ({
256
- phase: 1,
257
- active: "top" as const,
258
- ax: 2,
259
- ay,
260
- flash: false,
261
- white: false,
262
- })),
263
- ...Array.from({ length: 5 }, (_, ay) => ({
264
- phase: 2,
265
- active: "right" as const,
266
- ax: 5,
267
- ay,
268
- flash: false,
269
- white: false,
270
- })),
271
- { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
272
- { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
273
- { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
274
- { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
275
- { phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
276
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
277
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
278
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
279
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
280
- { phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
281
- ];
282
- const LAST_FRAME_INDEX = LOGO_FRAMES.length - 1;
283
-
284
- export const colorCell = (color: LogoColor): string => {
285
- const cg = (n: number) => GMAP[state.logoColorKey]?.[n] ?? "34";
286
- switch (color) {
287
- case "cyan":
288
- return "\x1b[36m██\x1b[39m";
289
- case "red":
290
- return "\x1b[31m██\x1b[39m";
291
- case "green":
292
- return "\x1b[32m██\x1b[39m";
293
- case "orange":
294
- case "flash":
295
- return "\x1b[33m██\x1b[39m";
296
- case "white":
297
- return "\x1b[39m██";
298
- case "logo":
299
- return `\x1b[${CMAP[state.logoColorKey]}m██\x1b[39m`;
300
- case "logoStripe":
301
- return `\x1b[${CMAP[state.logoColorKey]}m──\x1b[39m`;
302
- case "l1":
303
- case "l2":
304
- case "l3":
305
- case "l4":
306
- case "s1":
307
- case "s2":
308
- case "s3":
309
- case "s4":
310
- return `\x1b[${cg(GRADIENT_LEVEL[color])}m${color[0] === "l" ? "██" : "──"}\x1b[39m`;
311
- default:
312
- return " ";
313
- }
314
- };
315
-
316
- const WHITE_CELLS = new Set([
317
- "3,2",
318
- "3,3",
319
- "3,4",
320
- "4,2",
321
- "4,4",
322
- "5,2",
323
- "5,3",
324
- "5,5",
325
- "6,2",
326
- "6,5",
327
- ]);
328
- const P4_CYAN = new Set(["2,2", "2,3", "2,4", "3,4"]);
329
- const P4_RED = new Set(["3,2", "4,2", "4,3", "5,2"]);
330
- const P4_GREEN = new Set(["4,5", "5,5"]);
331
- const P5_CYAN = new Set(["3,2", "3,3", "3,4", "4,4"]);
332
- const P5_RED = new Set(["4,2", "5,2", "5,3", "6,2"]);
333
- const P5_GREEN = new Set(["5,5", "6,5"]);
334
- const EARLY_ORANGE = new Set(["6,1", "6,2", "6,3", "6,4"]);
335
- const LATE_GREEN = new Set(["4,5", "5,5", "6,5", "6,6"]);
336
- const PIECE_LEFT: [number, number][] = [
337
- [0, 0],
338
- [1, 0],
339
- [1, 1],
340
- [2, 0],
341
- ];
342
- const PIECE_TOP: [number, number][] = [
343
- [0, 0],
344
- [0, 1],
345
- [0, 2],
346
- [1, 2],
347
- ];
348
- const PIECE_RIGHT: [number, number][] = [
349
- [0, 0],
350
- [1, 0],
351
- [2, 0],
352
- [2, 1],
353
- ];
354
-
355
- export function logoCellColor(
356
- frame: LogoFrame,
357
- y: number,
358
- x: number,
359
- ): LogoColor {
360
- const key = `${y},${x}`;
361
-
362
- if (frame.white) return WHITE_CELLS.has(key) ? "white" : "panel";
363
- if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
364
-
365
- if (
366
- frame.active === "left" &&
367
- PIECE_LEFT.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
368
- )
369
- return "red";
370
- if (
371
- frame.active === "top" &&
372
- PIECE_TOP.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
373
- )
374
- return "cyan";
375
- if (
376
- frame.active === "right" &&
377
- PIECE_RIGHT.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
378
- )
379
- return "green";
380
-
381
- if (frame.phase === 6) {
382
- const isPi = WHITE_CELLS.has(key);
383
- const lvl = state.gradientOn
384
- ? y <= 3
385
- ? 1
386
- : y === 4
387
- ? 2
388
- : y === 5
389
- ? 3
390
- : 4
391
- : 0;
392
- if (isPi) return lvl > 0 ? (("l" + lvl) as LogoColor) : "logo";
393
- return state.stripeEnabled && y >= 2 && y <= LOGO_ROWS && x <= 6
394
- ? lvl > 0
395
- ? (("s" + lvl) as LogoColor)
396
- : "logoStripe"
397
- : "panel";
398
- }
399
- if (frame.phase === 4) {
400
- if (P4_CYAN.has(key)) return "cyan";
401
- if (P4_RED.has(key)) return "red";
402
- if (P4_GREEN.has(key)) return "green";
403
- return "panel";
404
- }
405
- if (frame.phase >= 5) {
406
- if (P5_CYAN.has(key)) return "cyan";
407
- if (P5_RED.has(key)) return "red";
408
- if (P5_GREEN.has(key)) return "green";
409
- return "panel";
410
- }
411
- if (frame.phase <= 3 && EARLY_ORANGE.has(key)) return "orange";
412
- if (frame.phase >= 2 && P4_CYAN.has(key)) return "cyan";
413
- if (frame.phase >= 1 && P4_RED.has(key)) return "red";
414
- if (frame.phase >= 3 && LATE_GREEN.has(key)) return "green";
415
- return "panel";
416
- }
417
-
418
- function piLogoFrame(frameIndex: number): string[] {
419
- if (frameIndex === LAST_FRAME_INDEX && state.customLogoLines) {
420
- return state.customLogoLines;
421
- }
422
- const frame = LOGO_FRAMES[frameIndex];
423
- const lines: string[] = [];
424
- for (let y = 1; y <= LOGO_ROWS; y++) {
425
- let line = "";
426
- for (let x = 1; x <= LOGO_COLS; x++)
427
- line += colorCell(logoCellColor(frame, y, x));
428
- lines.push(line);
429
- }
430
- return lines;
431
- }
432
-
433
- let PRECOMPUTED_LOGO_FRAMES: string[][] = LOGO_FRAMES.map((_, i) =>
434
- piLogoFrame(i),
435
- );
436
-
437
- function recomputeFrames(): void {
438
- PRECOMPUTED_LOGO_FRAMES = LOGO_FRAMES.map((_, i) => piLogoFrame(i));
439
- framesDirty = false;
440
- }
441
-
442
- /* ── Utilities ── */
443
- export function formatCwd(cwd: string): string {
444
- const home = homedir();
445
- return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
446
- }
447
-
448
- function padRight(text: string, width: number): string {
449
- const clipped = truncateToWidth(text, width, "");
450
- return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
451
- }
452
-
453
- export function buildRuntimePaths(
454
- agentDir: string,
455
- cwd?: string,
456
- configDirName: string = CONFIG_DIR_NAME,
457
- ) {
458
- return {
459
- agentDir,
460
- settingsPath: join(agentDir, "settings.json"),
461
- npmRoot: join(agentDir, "npm", "node_modules"),
462
- globalSkillsDir: join(agentDir, "skills"),
463
- globalAgentsPath: join(agentDir, "AGENTS.md"),
464
- projectSkillsDir: cwd ? join(cwd, configDirName, "skills") : undefined,
465
- projectAgentsPath: cwd ? join(cwd, configDirName, "AGENTS.md") : undefined,
466
- };
467
- }
468
-
469
- function getRuntimePaths(cwd?: string) {
470
- return buildRuntimePaths(getAgentDir(), cwd);
471
- }
472
-
473
- /* ── Stats ── */
474
- function computeStats(ctx: ExtensionContext) {
475
- const home = homedir();
476
- const paths = getRuntimePaths(ctx.cwd);
477
- const root = paths.npmRoot;
478
- const settingsPath = paths.settingsPath;
479
-
480
- let settingsPackages: string[] = [];
481
- try {
482
- const s = readSettings(settingsPath);
483
- if (s && Array.isArray(s.packages)) settingsPackages = s.packages;
484
- } catch {
485
- console.warn("pi-cc-header: failed to read settings.json");
486
- }
487
- const settingsNames = new Set(
488
- settingsPackages.map((p) => String(p).replace(/^npm:/, "")),
489
- );
490
- const installed = settingsPackages.length;
491
- let residue = 0;
492
- let prompts = 0;
493
- let pkgSkills = 0;
494
-
495
- function scanPkg(m: any, pkgDir: string, pkgName: string) {
496
- if (!m.pi) return;
497
- if (!settingsNames.has(pkgName)) residue++;
498
- if (Array.isArray(m.pi.prompts)) {
499
- for (const e of m.pi.prompts) {
500
- let d = join(pkgDir, e);
501
- if (!existsSync(d)) d = join(pkgDir, e.replace(/^(\.\.?\/)+/, ""));
502
- if (existsSync(d)) {
503
- try {
504
- prompts += readdirSync(d).filter((f: string) =>
505
- f.endsWith(".md"),
506
- ).length;
507
- } catch {
508
- console.warn("pi-cc-header: failed to read prompts dir", d);
509
- }
510
- }
511
- }
512
- }
513
- if (Array.isArray(m.pi.skills)) {
514
- for (const e of m.pi.skills) {
515
- let d = join(pkgDir, e);
516
- if (!existsSync(d)) d = join(pkgDir, e.replace(/^(\.\.?\/)+/, ""));
517
- if (existsSync(d)) {
518
- try {
519
- pkgSkills += readdirSync(d, { withFileTypes: true }).filter(
520
- (f) => f.isDirectory() || f.name.endsWith(".md"),
521
- ).length;
522
- } catch {
523
- console.warn("pi-cc-header: failed to read pkg skills dir", d);
524
- }
525
- }
526
- }
527
- }
528
- }
529
-
530
- if (existsSync(root)) {
531
- for (const name of readdirSync(root)) {
532
- if (name.startsWith(".")) continue;
533
- if (name.startsWith("@")) {
534
- let subs: string[];
535
- try {
536
- subs = readdirSync(join(root, name));
537
- } catch {
538
- console.warn(
539
- "pi-cc-header: failed to list scoped packages under",
540
- name,
541
- );
542
- continue;
543
- }
544
- for (const sub of subs) {
545
- const pj = join(root, name, sub, "package.json");
546
- if (!existsSync(pj)) continue;
547
- try {
548
- const m = JSON.parse(readFileSync(pj, "utf-8"));
549
- scanPkg(m, join(root, name, sub), `${name}/${sub}`);
550
- } catch {
551
- console.warn("pi-cc-header: failed to parse", pj);
552
- }
553
- }
554
- continue;
555
- }
556
- const pj = join(root, name, "package.json");
557
- if (!existsSync(pj)) continue;
558
- try {
559
- const m = JSON.parse(readFileSync(pj, "utf-8"));
560
- scanPkg(m, join(root, name), name);
561
- } catch {
562
- console.warn("pi-cc-header: failed to parse", pj);
563
- }
564
- }
565
- }
566
-
567
- const skillNames = new Set<string>();
568
- for (const d of [
569
- join(home, ".agents", "skills"),
570
- join(ctx.cwd, ".agents", "skills"),
571
- paths.globalSkillsDir,
572
- paths.projectSkillsDir,
573
- ].filter((d): d is string => !!d)) {
574
- if (!existsSync(d)) continue;
575
- try {
576
- for (const e of readdirSync(d, { withFileTypes: true })) {
577
- if (e.isDirectory() || e.name.endsWith(".md")) skillNames.add(e.name);
578
- }
579
- } catch {
580
- console.warn("pi-cc-header: failed to list skills dir", d);
581
- }
582
- }
583
-
584
- const globalAgents = existsSync(paths.globalAgentsPath);
585
- const projectAgents =
586
- existsSync(join(ctx.cwd, "AGENTS.md")) ||
587
- (paths.projectAgentsPath != null && existsSync(paths.projectAgentsPath));
588
-
589
- return {
590
- extensions: { installed, residue },
591
- skills: skillNames.size,
592
- pkgSkills,
593
- prompts,
594
- agents:
595
- globalAgents && projectAgents
596
- ? "Aa"
597
- : globalAgents
598
- ? "A"
599
- : projectAgents
600
- ? "a"
601
- : "",
602
- };
603
- }
604
-
605
- let cachedStats: ReturnType<typeof computeStats> | null = null;
606
- function invalidateStats(): void {
607
- cachedStats = null;
608
- }
609
-
610
- /* ── Component: startup header ── */
611
- class PiHeader implements Component {
612
- private frame = 0;
613
- private timer: ReturnType<typeof setTimeout> | null = null;
614
- private readonly stats: ReturnType<typeof computeStats>;
615
- private cachedInfoRows: Record<number, string> | null = null;
616
- private cachedInfoWidth = -1;
617
-
618
- constructor(
619
- private readonly pi: ExtensionAPI,
620
- private readonly ctx: ExtensionContext,
621
- private readonly tui: TUI,
622
- skipAnimation: boolean = false,
623
- ) {
624
- cachedStats ??= computeStats(ctx);
625
- this.stats = cachedStats!;
626
-
627
- if (skipAnimation) {
628
- this.frame = LAST_FRAME_INDEX;
629
- } else {
630
- const tick = () => {
631
- if (this.frame < LAST_FRAME_INDEX) {
632
- this.frame++;
633
- this.tui.requestRender();
634
- this.timer = setTimeout(tick, state.logoInterval);
635
- } else {
636
- this.timer = null;
637
- this.tui.requestRender();
638
- }
639
- };
640
- this.timer = setTimeout(tick, state.logoInterval);
641
- this.timer.unref?.();
642
- }
643
- }
644
-
645
- render(width: number): string[] {
646
- const theme = this.ctx.ui.theme;
647
- const muted = (s: string) => theme.fg("muted", s);
648
-
649
- const logoLines = PRECOMPUTED_LOGO_FRAMES[this.frame];
650
- const logoWidth = LOGO_PIXEL_WIDTH;
651
- const infoMaxWidth = Math.max(0, width - logoWidth);
652
-
653
- let infoStrings: string[];
654
- if (this.cachedInfoRows && this.cachedInfoWidth === width) {
655
- infoStrings = Object.values(this.cachedInfoRows);
656
- } else {
657
- const model = this.ctx.model?.id ?? "Default";
658
- const effort = this.pi.getThinkingLevel();
659
- const isLocal = /lm\s*studio|ollama|llama\.local|lmstudio/i.test(model);
660
- const cwd = formatCwd(this.ctx.cwd);
661
- const skillText = state.showPkgSkills
662
- ? `${this.stats.skills}|${this.stats.pkgSkills} skills`
663
- : `${this.stats.skills} skills`;
664
- const extText =
665
- this.stats.extensions.residue > 0
666
- ? `${this.stats.extensions.installed}(+${this.stats.extensions.residue}) extensions`
667
- : `${this.stats.extensions.installed} extensions`;
668
- const piText =
669
- state.versionColored >= 2
670
- ? `\x1b[${CMAP[state.logoColorKey]}mPi v${VERSION}\x1b[39m`
671
- : state.versionColored >= 1
672
- ? `\x1b[${CMAP[state.logoColorKey]}mPi\x1b[39m ${muted(`v${VERSION}`)}`
673
- : muted(`Pi v${VERSION}`);
674
-
675
- const modelLine = `${model} · ${effort}${this.stats.agents ? ` | ${this.stats.agents}` : ""}`;
676
-
677
- const rows: string[] = [
678
- piText,
679
- muted(skillText),
680
- muted(`${this.stats.prompts} prompts`),
681
- muted(extText),
682
- ];
683
- if (state.showModelLine && !isLocal) rows.push(muted(modelLine));
684
- if (state.sloganOn && state.slogan) {
685
- rows.push("");
686
- const sloganLines = state.slogan.split("\n");
687
- for (let idx = 0; idx < sloganLines.length; idx++) {
688
- const line = sloganLines[idx];
689
- const sloganW = visibleWidth(line);
690
- const sloganText =
691
- sloganW > infoMaxWidth
692
- ? truncateToWidth(line, infoMaxWidth - 3, "") + "..."
693
- : line;
694
-
695
- const isAuthor = idx === sloganLines.length - 1 && sloganLines.length > 1;
1
+ import {
2
+ CONFIG_DIR_NAME,
3
+ VERSION,
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { Component, TUI } from "@earendil-works/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import {
11
+ readFileSync,
12
+ writeFileSync,
13
+ readdirSync,
14
+ existsSync,
15
+ copyFileSync,
16
+ mkdirSync,
17
+ } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ /* ── Types ── */
23
+ interface CCHeaderConfig extends Record<string, any> {
24
+ readOnlyConfig?: boolean;
25
+ }
26
+
27
+ interface SettingsFile {
28
+ ccHeader?: CCHeaderConfig;
29
+ quietStartup?: boolean;
30
+ clearOnStart?: boolean;
31
+ packages?: string[];
32
+ [key: string]: any;
33
+ }
34
+
35
+ interface CCHeaderState {
36
+ logoColorKey: string;
37
+ versionColored: number; // 0=off 1=Pi only 2=Pi+ver
38
+ gradientOn: boolean;
39
+ stripeEnabled: boolean;
40
+ showPkgSkills: boolean;
41
+ logoInterval: number;
42
+ slogan: string;
43
+ sloganOn: boolean;
44
+ sloganColor: boolean;
45
+ sloganColorKey: string;
46
+ disabled: boolean;
47
+ showModelLine: boolean; // NEW: toggle model/thinking line
48
+ customLogoLines: string[] | null;
49
+ }
50
+
51
+ /* ── Constants ── */
52
+ const SPEEDS = [25, 50, 75, 100] as const;
53
+ const LOGO_COLS = 8;
54
+ const LOGO_ROWS = 7;
55
+ const LOGO_PIXEL_WIDTH = 14;
56
+ export const MAX_SLOGAN_LENGTH = 250;
57
+ const COLOR_NAMES: Record<string, string> = {
58
+ a: "anthropic",
59
+ c: "clawd",
60
+ r: "red",
61
+ o: "orange",
62
+ y: "yellow",
63
+ g: "green",
64
+ w: "white",
65
+ b: "blue",
66
+ p: "purple",
67
+ };
68
+ const DEFAULT_STATE: CCHeaderState = {
69
+ logoColorKey: "c",
70
+ versionColored: 1,
71
+ gradientOn: true,
72
+ stripeEnabled: true,
73
+ showPkgSkills: false,
74
+ logoInterval: SPEEDS[1],
75
+ slogan: "Code something that makes you proud",
76
+ sloganOn: true,
77
+ sloganColor: true,
78
+ sloganColorKey: "c",
79
+ quoteMode: false,
80
+ disabled: false,
81
+ showModelLine: true,
82
+ customLogoLines: null,
83
+ };
84
+ const CMAP: Record<string, string> = {
85
+ a: "38;2;217;119;87",
86
+ r: "31",
87
+ o: "38;5;208",
88
+ y: "38;5;226",
89
+ g: "38;2;20;180;20",
90
+ w: "38;5;15",
91
+ b: "38;2;40;130;220",
92
+ p: "38;5;129",
93
+ c: "38;2;251;73;52",
94
+ };
95
+ const QUOTE_AUTHOR_COMPLEMENT: Record<string, string> = {
96
+ a: "38;2;38;136;168",
97
+ r: "38;5;51",
98
+ o: "38;2;50;135;255",
99
+ y: "38;2;180;60;255",
100
+ g: "38;2;235;75;235",
101
+ w: "38;5;240",
102
+ b: "38;5;208",
103
+ p: "38;5;226",
104
+ c: "38;2;4;182;203",
105
+ };
106
+ const GMAP: Record<string, string[]> = {
107
+ a: ["38;2;217;119;87", "38;2;200;100;70", "38;2;170;80;55", "38;2;130;60;40"],
108
+ r: ["38;2;255;80;80", "38;2;220;40;40", "38;2;180;20;20", "38;2;140;10;10"],
109
+ o: [
110
+ "38;2;255;170;50",
111
+ "38;2;230;140;30",
112
+ "38;2;200;110;20",
113
+ "38;2;160;80;10",
114
+ ],
115
+ y: [
116
+ "38;2;255;255;80",
117
+ "38;2;230;230;40",
118
+ "38;2;200;200;20",
119
+ "38;2;160;160;10",
120
+ ],
121
+ g: ["38;2;80;255;80", "38;2;40;220;40", "38;2;20;180;20", "38;2;10;140;10"],
122
+ w: [
123
+ "38;2;230;230;210",
124
+ "38;2;190;190;170",
125
+ "38;2;140;140;120",
126
+ "38;2;100;100;85",
127
+ ],
128
+ b: [
129
+ "38;2;100;180;255",
130
+ "38;2;70;160;245",
131
+ "38;2;40;130;220",
132
+ "38;2;20;100;195",
133
+ ],
134
+ p: [
135
+ "38;2;200;100;255",
136
+ "38;2;170;70;230",
137
+ "38;2;140;40;200",
138
+ "38;2;110;20;160",
139
+ ],
140
+ c: ["38;2;251;73;52", "38;2;220;60;40", "38;2;190;45;30", "38;2;155;30;20"],
141
+ };
142
+ const GRADIENT_LEVEL: Record<string, number> = {
143
+ l1: 0,
144
+ l2: 1,
145
+ l3: 2,
146
+ l4: 3,
147
+ s1: 0,
148
+ s2: 1,
149
+ s3: 2,
150
+ s4: 3,
151
+ };
152
+
153
+ /* ── Runtime state ── */
154
+ let state: CCHeaderState = { ...DEFAULT_STATE };
155
+ let framesDirty = true;
156
+
157
+ /* ── Quotes ── */
158
+ interface Quote {
159
+ quote: string;
160
+ author: string;
161
+ }
162
+
163
+ let quotesCache: Quote[] | null = null;
164
+
165
+ function loadQuotes(ctx: ExtensionContext): Quote[] {
166
+ if (quotesCache) return quotesCache;
167
+
168
+ const projectRoot = ctx.cwd;
169
+ const quotesPath = join(projectRoot, "quotes.json");
170
+
171
+ if (!existsSync(quotesPath)) {
172
+ // Try the package directory as fallback
173
+ const __filename = fileURLToPath(import.meta.url);
174
+ const pkgDir = dirname(dirname(__filename));
175
+ const fallbackPath = join(pkgDir, "quotes.json");
176
+ if (existsSync(fallbackPath)) {
177
+ try {
178
+ quotesCache = JSON.parse(readFileSync(fallbackPath, "utf-8"));
179
+ return quotesCache!;
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+ return [];
185
+ }
186
+
187
+ try {
188
+ quotesCache = JSON.parse(readFileSync(quotesPath, "utf-8"));
189
+ return quotesCache!;
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ export function formatQuote(quote: string, author: string, punctBonus = 25): string {
196
+ const words = quote.trim().split(/\s+/);
197
+ if (words.length <= 10) {
198
+ return `"${quote}"\n${author}`;
199
+ }
200
+
201
+ let bestI = 1;
202
+ let bestScore = Infinity;
203
+ for (let i = 1; i < words.length; i++) {
204
+ const p1 = words.slice(0, i).join(" ");
205
+ const p2 = words.slice(i).join(" ");
206
+ const lenDiff = Math.abs(p1.length - p2.length);
207
+ const lastChar = p1[p1.length - 1];
208
+ const hasPunct = [":", ".", ",", ";", "-", "—"].includes(lastChar);
209
+ const score = lenDiff - (hasPunct ? punctBonus : 0);
210
+ if (score < bestScore) {
211
+ bestScore = score;
212
+ bestI = i;
213
+ }
214
+ }
215
+
216
+ const line1 = `"${words.slice(0, bestI).join(" ")}`;
217
+ const line2 = `${words.slice(bestI).join(" ")}"`;
218
+ return `${line1}\n${line2}\n${author}`;
219
+ }
220
+
221
+ export function getRandomQuote(ctx: ExtensionContext): string | null {
222
+ const quotes = loadQuotes(ctx);
223
+ if (quotes.length === 0) return null;
224
+ const random = quotes[Math.floor(Math.random() * quotes.length)];
225
+ return formatQuote(random.quote, random.author);
226
+ }
227
+
228
+ export function formatQuoteAuthor(author: string, colorKey: string): string {
229
+ const color = QUOTE_AUTHOR_COMPLEMENT[colorKey] ?? QUOTE_AUTHOR_COMPLEMENT.c;
230
+ return `[${color}m${author}\x1b[39m`;
231
+ }
232
+
233
+ /* ── Pi logo animation ── */
234
+ type LogoColor =
235
+ | "panel"
236
+ | "cyan"
237
+ | "red"
238
+ | "green"
239
+ | "orange"
240
+ | "flash"
241
+ | "logo"
242
+ | "logoStripe"
243
+ | "white"
244
+ | "l1"
245
+ | "l2"
246
+ | "l3"
247
+ | "l4"
248
+ | "s1"
249
+ | "s2"
250
+ | "s3"
251
+ | "s4";
252
+ type LogoPhase = "left" | "top" | "right" | "none";
253
+ type LogoFrame = {
254
+ phase: number;
255
+ active: LogoPhase;
256
+ ax: number;
257
+ ay: number;
258
+ flash: boolean;
259
+ white: boolean;
260
+ };
261
+
262
+ const LOGO_FRAMES: LogoFrame[] = [
263
+ ...Array.from({ length: 4 }, (_, ay) => ({
264
+ phase: 0,
265
+ active: "left" as const,
266
+ ax: 2,
267
+ ay,
268
+ flash: false,
269
+ white: false,
270
+ })),
271
+ ...Array.from({ length: 3 }, (_, ay) => ({
272
+ phase: 1,
273
+ active: "top" as const,
274
+ ax: 2,
275
+ ay,
276
+ flash: false,
277
+ white: false,
278
+ })),
279
+ ...Array.from({ length: 5 }, (_, ay) => ({
280
+ phase: 2,
281
+ active: "right" as const,
282
+ ax: 5,
283
+ ay,
284
+ flash: false,
285
+ white: false,
286
+ })),
287
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
288
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
289
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
290
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
291
+ { phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
292
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
293
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
294
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
295
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
296
+ { phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
297
+ ];
298
+ const LAST_FRAME_INDEX = LOGO_FRAMES.length - 1;
299
+
300
+ export const colorCell = (color: LogoColor): string => {
301
+ const cg = (n: number) => GMAP[state.logoColorKey]?.[n] ?? "34";
302
+ switch (color) {
303
+ case "cyan":
304
+ return "\x1b[36m██\x1b[39m";
305
+ case "red":
306
+ return "\x1b[31m██\x1b[39m";
307
+ case "green":
308
+ return "\x1b[32m██\x1b[39m";
309
+ case "orange":
310
+ case "flash":
311
+ return "\x1b[33m██\x1b[39m";
312
+ case "white":
313
+ return "\x1b[39m██";
314
+ case "logo":
315
+ return `\x1b[${CMAP[state.logoColorKey]}m██\x1b[39m`;
316
+ case "logoStripe":
317
+ return `\x1b[${CMAP[state.logoColorKey]}m──\x1b[39m`;
318
+ case "l1":
319
+ case "l2":
320
+ case "l3":
321
+ case "l4":
322
+ case "s1":
323
+ case "s2":
324
+ case "s3":
325
+ case "s4":
326
+ return `\x1b[${cg(GRADIENT_LEVEL[color])}m${color[0] === "l" ? "██" : "──"}\x1b[39m`;
327
+ default:
328
+ return " ";
329
+ }
330
+ };
331
+
332
+ const WHITE_CELLS = new Set([
333
+ "3,2",
334
+ "3,3",
335
+ "3,4",
336
+ "4,2",
337
+ "4,4",
338
+ "5,2",
339
+ "5,3",
340
+ "5,5",
341
+ "6,2",
342
+ "6,5",
343
+ ]);
344
+ const P4_CYAN = new Set(["2,2", "2,3", "2,4", "3,4"]);
345
+ const P4_RED = new Set(["3,2", "4,2", "4,3", "5,2"]);
346
+ const P4_GREEN = new Set(["4,5", "5,5"]);
347
+ const P5_CYAN = new Set(["3,2", "3,3", "3,4", "4,4"]);
348
+ const P5_RED = new Set(["4,2", "5,2", "5,3", "6,2"]);
349
+ const P5_GREEN = new Set(["5,5", "6,5"]);
350
+ const EARLY_ORANGE = new Set(["6,1", "6,2", "6,3", "6,4"]);
351
+ const LATE_GREEN = new Set(["4,5", "5,5", "6,5", "6,6"]);
352
+ const PIECE_LEFT: [number, number][] = [
353
+ [0, 0],
354
+ [1, 0],
355
+ [1, 1],
356
+ [2, 0],
357
+ ];
358
+ const PIECE_TOP: [number, number][] = [
359
+ [0, 0],
360
+ [0, 1],
361
+ [0, 2],
362
+ [1, 2],
363
+ ];
364
+ const PIECE_RIGHT: [number, number][] = [
365
+ [0, 0],
366
+ [1, 0],
367
+ [2, 0],
368
+ [2, 1],
369
+ ];
370
+
371
+ export function logoCellColor(
372
+ frame: LogoFrame,
373
+ y: number,
374
+ x: number,
375
+ ): LogoColor {
376
+ const key = `${y},${x}`;
377
+
378
+ if (frame.white) return WHITE_CELLS.has(key) ? "white" : "panel";
379
+ if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
380
+
381
+ if (
382
+ frame.active === "left" &&
383
+ PIECE_LEFT.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
384
+ )
385
+ return "red";
386
+ if (
387
+ frame.active === "top" &&
388
+ PIECE_TOP.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
389
+ )
390
+ return "cyan";
391
+ if (
392
+ frame.active === "right" &&
393
+ PIECE_RIGHT.some(([dy, dx]) => y === frame.ay + dy && x === frame.ax + dx)
394
+ )
395
+ return "green";
396
+
397
+ if (frame.phase === 6) {
398
+ const isPi = WHITE_CELLS.has(key);
399
+ const lvl = state.gradientOn
400
+ ? y <= 3
401
+ ? 1
402
+ : y === 4
403
+ ? 2
404
+ : y === 5
405
+ ? 3
406
+ : 4
407
+ : 0;
408
+ if (isPi) return lvl > 0 ? (("l" + lvl) as LogoColor) : "logo";
409
+ return state.stripeEnabled && y >= 2 && y <= LOGO_ROWS && x <= 6
410
+ ? lvl > 0
411
+ ? (("s" + lvl) as LogoColor)
412
+ : "logoStripe"
413
+ : "panel";
414
+ }
415
+ if (frame.phase === 4) {
416
+ if (P4_CYAN.has(key)) return "cyan";
417
+ if (P4_RED.has(key)) return "red";
418
+ if (P4_GREEN.has(key)) return "green";
419
+ return "panel";
420
+ }
421
+ if (frame.phase >= 5) {
422
+ if (P5_CYAN.has(key)) return "cyan";
423
+ if (P5_RED.has(key)) return "red";
424
+ if (P5_GREEN.has(key)) return "green";
425
+ return "panel";
426
+ }
427
+ if (frame.phase <= 3 && EARLY_ORANGE.has(key)) return "orange";
428
+ if (frame.phase >= 2 && P4_CYAN.has(key)) return "cyan";
429
+ if (frame.phase >= 1 && P4_RED.has(key)) return "red";
430
+ if (frame.phase >= 3 && LATE_GREEN.has(key)) return "green";
431
+ return "panel";
432
+ }
433
+
434
+ function piLogoFrame(frameIndex: number): string[] {
435
+ if (frameIndex === LAST_FRAME_INDEX && state.customLogoLines) {
436
+ return state.customLogoLines;
437
+ }
438
+ const frame = LOGO_FRAMES[frameIndex];
439
+ const lines: string[] = [];
440
+ for (let y = 1; y <= LOGO_ROWS; y++) {
441
+ let line = "";
442
+ for (let x = 1; x <= LOGO_COLS; x++)
443
+ line += colorCell(logoCellColor(frame, y, x));
444
+ lines.push(line);
445
+ }
446
+ return lines;
447
+ }
448
+
449
+ let PRECOMPUTED_LOGO_FRAMES: string[][] = LOGO_FRAMES.map((_, i) =>
450
+ piLogoFrame(i),
451
+ );
452
+
453
+ function recomputeFrames(): void {
454
+ PRECOMPUTED_LOGO_FRAMES = LOGO_FRAMES.map((_, i) => piLogoFrame(i));
455
+ framesDirty = false;
456
+ }
457
+
458
+ /* ── Utilities ── */
459
+ export function formatCwd(cwd: string): string {
460
+ const home = homedir();
461
+ return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
462
+ }
463
+
464
+ function padRight(text: string, width: number): string {
465
+ const clipped = truncateToWidth(text, width, "");
466
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
467
+ }
468
+
469
+ export function buildRuntimePaths(
470
+ agentDir: string,
471
+ cwd?: string,
472
+ configDirName: string = CONFIG_DIR_NAME,
473
+ ) {
474
+ return {
475
+ agentDir,
476
+ settingsPath: join(agentDir, "settings.json"),
477
+ npmRoot: join(agentDir, "npm", "node_modules"),
478
+ globalSkillsDir: join(agentDir, "skills"),
479
+ globalAgentsPath: join(agentDir, "AGENTS.md"),
480
+ projectSkillsDir: cwd ? join(cwd, configDirName, "skills") : undefined,
481
+ projectAgentsPath: cwd ? join(cwd, configDirName, "AGENTS.md") : undefined,
482
+ };
483
+ }
484
+
485
+ function getRuntimePaths(cwd?: string) {
486
+ return buildRuntimePaths(getAgentDir(), cwd);
487
+ }
488
+
489
+ /* ── Stats ── */
490
+ function computeStats(ctx: ExtensionContext) {
491
+ const home = homedir();
492
+ const paths = getRuntimePaths(ctx.cwd);
493
+ const root = paths.npmRoot;
494
+ const settingsPath = paths.settingsPath;
495
+
496
+ let settingsPackages: string[] = [];
497
+ try {
498
+ const s = readSettings(settingsPath);
499
+ if (s && Array.isArray(s.packages)) settingsPackages = s.packages;
500
+ } catch {
501
+ console.warn("pi-cc-header: failed to read settings.json");
502
+ }
503
+ const settingsNames = new Set(
504
+ settingsPackages.map((p) => String(p).replace(/^npm:/, "")),
505
+ );
506
+ const installed = settingsPackages.length;
507
+ let residue = 0;
508
+ let prompts = 0;
509
+ let pkgSkills = 0;
510
+
511
+ function scanPkg(m: any, pkgDir: string, pkgName: string) {
512
+ if (!m.pi) return;
513
+ if (!settingsNames.has(pkgName)) residue++;
514
+ if (Array.isArray(m.pi.prompts)) {
515
+ for (const e of m.pi.prompts) {
516
+ let d = join(pkgDir, e);
517
+ if (!existsSync(d)) d = join(pkgDir, e.replace(/^(\.\.?\/)+/, ""));
518
+ if (existsSync(d)) {
519
+ try {
520
+ prompts += readdirSync(d).filter((f: string) =>
521
+ f.endsWith(".md"),
522
+ ).length;
523
+ } catch {
524
+ console.warn("pi-cc-header: failed to read prompts dir", d);
525
+ }
526
+ }
527
+ }
528
+ }
529
+ if (Array.isArray(m.pi.skills)) {
530
+ for (const e of m.pi.skills) {
531
+ let d = join(pkgDir, e);
532
+ if (!existsSync(d)) d = join(pkgDir, e.replace(/^(\.\.?\/)+/, ""));
533
+ if (existsSync(d)) {
534
+ try {
535
+ pkgSkills += readdirSync(d, { withFileTypes: true }).filter(
536
+ (f) => f.isDirectory() || f.name.endsWith(".md"),
537
+ ).length;
538
+ } catch {
539
+ console.warn("pi-cc-header: failed to read pkg skills dir", d);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ }
545
+
546
+ if (existsSync(root)) {
547
+ for (const name of readdirSync(root)) {
548
+ if (name.startsWith(".")) continue;
549
+ if (name.startsWith("@")) {
550
+ let subs: string[];
551
+ try {
552
+ subs = readdirSync(join(root, name));
553
+ } catch {
554
+ console.warn(
555
+ "pi-cc-header: failed to list scoped packages under",
556
+ name,
557
+ );
558
+ continue;
559
+ }
560
+ for (const sub of subs) {
561
+ const pj = join(root, name, sub, "package.json");
562
+ if (!existsSync(pj)) continue;
563
+ try {
564
+ const m = JSON.parse(readFileSync(pj, "utf-8"));
565
+ scanPkg(m, join(root, name, sub), `${name}/${sub}`);
566
+ } catch {
567
+ console.warn("pi-cc-header: failed to parse", pj);
568
+ }
569
+ }
570
+ continue;
571
+ }
572
+ const pj = join(root, name, "package.json");
573
+ if (!existsSync(pj)) continue;
574
+ try {
575
+ const m = JSON.parse(readFileSync(pj, "utf-8"));
576
+ scanPkg(m, join(root, name), name);
577
+ } catch {
578
+ console.warn("pi-cc-header: failed to parse", pj);
579
+ }
580
+ }
581
+ }
582
+
583
+ const skillNames = new Set<string>();
584
+ for (const d of [
585
+ join(home, ".agents", "skills"),
586
+ join(ctx.cwd, ".agents", "skills"),
587
+ paths.globalSkillsDir,
588
+ paths.projectSkillsDir,
589
+ ].filter((d): d is string => !!d)) {
590
+ if (!existsSync(d)) continue;
591
+ try {
592
+ for (const e of readdirSync(d, { withFileTypes: true })) {
593
+ if (e.isDirectory() || e.name.endsWith(".md")) skillNames.add(e.name);
594
+ }
595
+ } catch {
596
+ console.warn("pi-cc-header: failed to list skills dir", d);
597
+ }
598
+ }
599
+
600
+ const globalAgents = existsSync(paths.globalAgentsPath);
601
+ const projectAgents =
602
+ existsSync(join(ctx.cwd, "AGENTS.md")) ||
603
+ (paths.projectAgentsPath != null && existsSync(paths.projectAgentsPath));
604
+
605
+ return {
606
+ extensions: { installed, residue },
607
+ skills: skillNames.size,
608
+ pkgSkills,
609
+ prompts,
610
+ agents:
611
+ globalAgents && projectAgents
612
+ ? "Aa"
613
+ : globalAgents
614
+ ? "A"
615
+ : projectAgents
616
+ ? "a"
617
+ : "",
618
+ };
619
+ }
620
+
621
+ let cachedStats: ReturnType<typeof computeStats> | null = null;
622
+ function invalidateStats(): void {
623
+ cachedStats = null;
624
+ }
625
+
626
+ /* ── Component: startup header ── */
627
+ class PiHeader implements Component {
628
+ private frame = 0;
629
+ private timer: ReturnType<typeof setTimeout> | null = null;
630
+ private readonly stats: ReturnType<typeof computeStats>;
631
+ private cachedInfoRows: Record<number, string> | null = null;
632
+ private cachedInfoWidth = -1;
633
+
634
+ constructor(
635
+ private readonly pi: ExtensionAPI,
636
+ private readonly ctx: ExtensionContext,
637
+ private readonly tui: TUI,
638
+ skipAnimation: boolean = false,
639
+ ) {
640
+ cachedStats ??= computeStats(ctx);
641
+ this.stats = cachedStats!;
642
+
643
+ if (skipAnimation) {
644
+ this.frame = LAST_FRAME_INDEX;
645
+ } else {
646
+ const tick = () => {
647
+ if (this.frame < LAST_FRAME_INDEX) {
648
+ this.frame++;
649
+ this.tui.requestRender();
650
+ this.timer = setTimeout(tick, state.logoInterval);
651
+ } else {
652
+ this.timer = null;
653
+ this.tui.requestRender();
654
+ }
655
+ };
656
+ this.timer = setTimeout(tick, state.logoInterval);
657
+ this.timer.unref?.();
658
+ }
659
+ }
660
+
661
+ render(width: number): string[] {
662
+ const theme = this.ctx.ui.theme;
663
+ const muted = (s: string) => theme.fg("muted", s);
664
+
665
+ const logoLines = PRECOMPUTED_LOGO_FRAMES[this.frame];
666
+ const logoWidth = LOGO_PIXEL_WIDTH;
667
+ const infoMaxWidth = Math.max(0, width - logoWidth);
668
+
669
+ let infoStrings: string[];
670
+ if (this.cachedInfoRows && this.cachedInfoWidth === width) {
671
+ infoStrings = Object.values(this.cachedInfoRows);
672
+ } else {
673
+ const model = this.ctx.model?.id ?? "Default";
674
+ const effort = this.pi.getThinkingLevel();
675
+ const isLocal = /lm\s*studio|ollama|llama\.local|lmstudio/i.test(model);
676
+ const cwd = formatCwd(this.ctx.cwd);
677
+ const skillText = state.showPkgSkills
678
+ ? `${this.stats.skills}|${this.stats.pkgSkills} skills`
679
+ : `${this.stats.skills} skills`;
680
+ const extText =
681
+ this.stats.extensions.residue > 0
682
+ ? `${this.stats.extensions.installed}(+${this.stats.extensions.residue}) extensions`
683
+ : `${this.stats.extensions.installed} extensions`;
684
+ const piText =
685
+ state.versionColored >= 2
686
+ ? `\x1b[${CMAP[state.logoColorKey]}mPi v${VERSION}\x1b[39m`
687
+ : state.versionColored >= 1
688
+ ? `\x1b[${CMAP[state.logoColorKey]}mPi\x1b[39m ${muted(`v${VERSION}`)}`
689
+ : muted(`Pi v${VERSION}`);
690
+
691
+ const modelLine = `${model} · ${effort}${this.stats.agents ? ` | ${this.stats.agents}` : ""}`;
692
+
693
+ const rows: string[] = [
694
+ piText,
695
+ muted(skillText),
696
+ muted(`${this.stats.prompts} prompts`),
697
+ muted(extText),
698
+ ];
699
+ if (state.showModelLine && !isLocal) rows.push(muted(modelLine));
700
+ if (state.sloganOn && state.slogan) {
701
+ rows.push("");
702
+ const sloganLines = state.slogan.split("\n");
703
+ let quoteLineWidth = 0; // Track width of the quote line for author right-justification
704
+ for (let idx = 0; idx < sloganLines.length; idx++) {
705
+ const line = sloganLines[idx];
706
+ const sloganW = visibleWidth(line);
707
+ const sloganText =
708
+ sloganW > infoMaxWidth
709
+ ? truncateToWidth(line, infoMaxWidth - 3, "") + "..."
710
+ : line;
711
+
712
+ const isAuthor = idx === sloganLines.length - 1 && sloganLines.length > 1;
696
713
  if (isAuthor) {
697
- rows.push(`\x1b[${CMAP[state.sloganColorKey]}m~${sloganText}\x1b[39m`);
698
- } else {
714
+ // Right-justify author to match the width of the quote line above
715
+ const authorW = visibleWidth(sloganText);
716
+ const padding = Math.max(0, quoteLineWidth - authorW);
717
+ const paddedAuthor = " ".repeat(padding) + sloganText;
699
718
  rows.push(
700
719
  state.sloganColor
701
- ? `\x1b[1m\x1b[${CMAP[state.sloganColorKey]}m${sloganText}\x1b[39m\x1b[22m`
702
- : muted(`\x1b[1m${sloganText}\x1b[22m`),
703
- );
704
- }
705
- }
706
- } else {
707
- rows.push(muted(this.stats.agents ? `${this.stats.agents} · ${cwd}` : cwd));
708
- }
709
-
710
- infoStrings = rows;
711
- this.cachedInfoRows = Object.fromEntries(rows.map((r, i) => [i, r]));
712
- this.cachedInfoWidth = width;
713
- }
714
-
715
- // Logo rows 1..5 (y=2..6) paired with info rows 0..3 at indices 2..5
716
- const LOGO_INFO_MAP: Record<number, number> = { 2: 0, 3: 1, 4: 2, 5: 3 };
717
-
718
- // Build combined logo+info lines
719
- const combinedLines: string[] = [];
720
- for (let i = 1; i < logoLines.length - 1; i++) {
721
- const infoIdx = LOGO_INFO_MAP[i];
722
- const right = infoIdx != null ? infoStrings[infoIdx] : "";
723
- combinedLines.push(padRight(logoLines[i], logoWidth) + right);
724
- }
725
-
726
- const maxLineW = Math.max(...combinedLines.map((s) => visibleWidth(s)));
727
- const leftPad = Math.max(0, Math.floor((width - maxLineW) / 2));
728
-
729
- const lines: string[] = [];
730
- for (const cl of combinedLines) {
731
- lines.push(" ".repeat(leftPad) + truncateToWidth(cl, width - leftPad, ""));
732
- }
733
-
734
- // Remaining info rows (model, slogan, cwd) centered independently
735
- const center = (text: string, w: number): string => {
736
- const vw = visibleWidth(text);
737
- const pad = Math.max(0, Math.floor((w - vw) / 2));
738
- return " ".repeat(pad) + text + " ".repeat(Math.max(0, w - pad - vw));
739
- };
740
- for (let i = 4; i < infoStrings.length; i++) {
741
- const row = infoStrings[i];
742
- lines.push(
743
- row === "" ? "" : center(truncateToWidth(row, width, ""), width),
744
- );
745
- }
746
- return lines;
747
- }
748
-
749
- invalidate(): void {}
750
- reapply(): void {
751
- this.cachedInfoRows = null;
752
- this.tui.requestRender();
753
- }
754
- dispose(): void {
755
- if (this.timer != null) clearTimeout(this.timer);
756
- }
757
- }
758
-
759
- /* ── Mount ── */
760
- let active: PiHeader | undefined;
761
- let isResuming = false;
762
-
763
- function apply(
764
- pi: ExtensionAPI,
765
- ctx: ExtensionContext,
766
- clearMode: "full" | "viewport" | "none",
767
- skipAnimation: boolean = false,
768
- ) {
769
- if (ctx.mode !== "tui") return;
770
- if (clearMode === "full") {
771
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
772
- } else if (clearMode === "viewport") {
773
- process.stdout.write("\x1b[2J");
774
- }
775
- ctx.ui.setHeader((tui) => {
776
- active?.dispose();
777
- active = new PiHeader(pi, ctx, tui, skipAnimation);
778
- return active;
779
- });
780
- }
781
-
782
- /* ── State <-> config serialization ── */
783
- export const pick = <T>(
784
- val: unknown,
785
- guard: (v: unknown) => boolean,
786
- fallback: T,
787
- ): T => (guard(val) ? (val as T) : fallback);
788
-
789
- export function stateFromConfig(h: Record<string, any>): CCHeaderState {
790
- return {
791
- logoColorKey: pick(
792
- h.color,
793
- (v) => !!CMAP[v as string],
794
- DEFAULT_STATE.logoColorKey,
795
- ),
796
- versionColored: pick(
797
- h.ver,
798
- (v) => typeof v === "number",
799
- DEFAULT_STATE.versionColored,
800
- ),
801
- gradientOn: pick(
802
- h.grad,
803
- (v) => typeof v === "boolean",
804
- DEFAULT_STATE.gradientOn,
805
- ),
806
- stripeEnabled: pick(
807
- h.lines,
808
- (v) => typeof v === "boolean",
809
- DEFAULT_STATE.stripeEnabled,
810
- ),
811
- showPkgSkills: pick(
812
- h.pkg,
813
- (v) => typeof v === "boolean",
814
- DEFAULT_STATE.showPkgSkills,
815
- ),
816
- logoInterval: pick(
817
- h.speed,
818
- (v) => typeof v === "number" && (SPEEDS as readonly number[]).includes(v),
819
- DEFAULT_STATE.logoInterval,
820
- ),
821
- slogan: pick(
822
- h.slogan,
823
- (v) => typeof v === "string" && v.length <= MAX_SLOGAN_LENGTH,
824
- DEFAULT_STATE.slogan,
825
- ),
826
- sloganOn: pick(
827
- h.sloganOn,
828
- (v) => typeof v === "boolean",
829
- DEFAULT_STATE.sloganOn,
830
- ),
831
- sloganColor: pick(
832
- h.sloganColor,
833
- (v) => typeof v === "boolean",
834
- DEFAULT_STATE.sloganColor,
835
- ),
836
- sloganColorKey: pick(
837
- h.sloganColorKey ?? h.sloganColorCode,
838
- (v) => !!CMAP[v as string],
839
- DEFAULT_STATE.sloganColorKey,
840
- ),
841
- quoteMode: pick(
842
- (h.quoteMode ?? h.stoicMode),
843
- (v) => typeof v === "boolean",
844
- DEFAULT_STATE.quoteMode,
845
- ),
846
- disabled: pick(
847
- h.disabled,
848
- (v) => typeof v === "boolean",
849
- DEFAULT_STATE.disabled,
850
- ),
851
- showModelLine: pick(
852
- h.showModelLine,
853
- (v) => typeof v === "boolean",
854
- DEFAULT_STATE.showModelLine,
855
- ),
856
- customLogoLines: pick(
857
- h.customLogo,
858
- (v) => Array.isArray(v) && v.every((l) => typeof l === "string"),
859
- DEFAULT_STATE.customLogoLines,
860
- ),
861
- };
862
- }
863
-
864
- function stateToConfig(): Record<string, any> {
865
- return {
866
- color: state.logoColorKey,
867
- ver: state.versionColored,
868
- grad: state.gradientOn,
869
- lines: state.stripeEnabled,
870
- pkg: state.showPkgSkills,
871
- speed: state.logoInterval,
872
- slogan: state.slogan,
873
- sloganOn: state.sloganOn,
874
- sloganColor: state.sloganColor,
875
- sloganColorKey: state.sloganColorKey,
876
- quoteMode: state.quoteMode,
877
- disabled: state.disabled,
878
- showModelLine: state.showModelLine,
879
- customLogo: state.customLogoLines,
880
- };
881
- }
882
-
883
- function getCCHeaderConfig(
884
- settings: SettingsFile | null | undefined,
885
- ): CCHeaderConfig {
886
- const ccHeader = settings?.ccHeader;
887
- return ccHeader && typeof ccHeader === "object" && !Array.isArray(ccHeader)
888
- ? ccHeader
889
- : {};
890
- }
891
-
892
- export function configWritesEnabled(
893
- settings: SettingsFile | null | undefined,
894
- ): boolean {
895
- return getCCHeaderConfig(settings).readOnlyConfig !== true;
896
- }
897
-
898
- type PersistResult = "saved" | "skipped" | "failed";
899
-
900
- function isReadonlyWriteError(error: unknown): boolean {
901
- if (!error || typeof error !== "object" || !("code" in error)) return false;
902
- return ["EROFS", "EACCES", "EPERM"].includes(
903
- String((error as NodeJS.ErrnoException).code),
904
- );
905
- }
906
-
907
- /* ── Config update ── */
908
- function updateState(
909
- ctx: ExtensionContext,
910
- applyAndPersist: (msg: string) => void,
911
- updater: (s: CCHeaderState) => string | null,
912
- skipFrames: boolean = false,
913
- ): void {
914
- if (state.disabled) {
915
- ctx.ui.notify(
916
- "Command unavailable: pi-cc-header disabled. Use /pch --tg to enable.",
917
- "info",
918
- );
919
- return;
920
- }
921
-
922
- const prevColor = state.logoColorKey;
923
- const prevGrad = state.gradientOn;
924
- const prevStripe = state.stripeEnabled;
925
-
926
- const msg = updater(state);
927
- if (msg === null) return;
928
-
929
- if (
930
- (!skipFrames && state.logoColorKey !== prevColor) ||
931
- state.gradientOn !== prevGrad ||
932
- state.stripeEnabled !== prevStripe
933
- ) {
934
- framesDirty = true;
935
- }
936
- if (framesDirty) recomputeFrames();
937
-
938
- applyAndPersist(msg);
939
- }
940
-
941
- function emptySettings(): SettingsFile {
942
- return {};
943
- }
944
-
945
- function parseSettingsFile(settingsPath: string): SettingsFile {
946
- const content = readFileSync(settingsPath, "utf-8");
947
- try {
948
- const parsed = JSON.parse(content);
949
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
950
- throw new Error("pi-cc-header: settings.json must contain an object");
951
- }
952
- return parsed;
953
- } catch (error) {
954
- throw new Error("pi-cc-header: invalid settings.json", { cause: error });
955
- }
956
- }
957
-
958
- function backupCorruptedSettings(settingsPath: string): void {
959
- try {
960
- if (!existsSync(settingsPath)) return;
961
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
962
- const bak = settingsPath.replace(/\.json$/, `.bak.${ts}.json`);
963
- copyFileSync(settingsPath, bak);
964
- console.error("pi-cc-header: corrupted settings.json backed up to", bak);
965
- } catch {
966
- console.error("pi-cc-header: failed to read or back up settings.json");
967
- }
968
- }
969
-
970
- function restoreDefaultSettingsFile(settingsPath: string): void {
971
- try {
972
- mkdirSync(dirname(settingsPath), { recursive: true });
973
- writeFileSync(settingsPath, "{\n}\n", "utf-8");
974
- } catch {
975
- console.error("pi-cc-header: failed to restore default settings.json");
976
- }
977
- }
978
-
979
- function readSettings(settingsPath: string): SettingsFile | null {
980
- if (!existsSync(settingsPath)) return emptySettings();
981
-
982
- try {
983
- return parseSettingsFile(settingsPath);
984
- } catch {
985
- backupCorruptedSettings(settingsPath);
986
- restoreDefaultSettingsFile(settingsPath);
987
- return null;
988
- }
989
- }
990
-
991
- /* ── Entry ── */
992
- export default function (pi: ExtensionAPI) {
993
- const settingsPath = getRuntimePaths().settingsPath;
994
-
995
- const saveSettings = (s: SettingsFile): boolean => {
996
- try {
997
- mkdirSync(dirname(settingsPath), { recursive: true });
998
- writeFileSync(settingsPath, `${JSON.stringify(s, null, 2)}\n`, "utf-8");
999
- return true;
1000
- } catch (error) {
1001
- if (isReadonlyWriteError(error)) return false;
1002
- console.error("pi-cc-header: failed to write settings.json");
1003
- return false;
1004
- }
1005
- };
1006
-
1007
- const withPersistenceNote = (
1008
- msg: string,
1009
- settings: SettingsFile,
1010
- persistResult: PersistResult,
1011
- ) => {
1012
- if (persistResult === "saved") return msg;
1013
- if (persistResult === "skipped") {
1014
- return `${msg} (session only; ccHeader.readOnlyConfig=true)`;
1015
- }
1016
- return configWritesEnabled(settings)
1017
- ? `${msg} (not saved: settings.json is not writable)`
1018
- : `${msg} (session only; ccHeader.readOnlyConfig=true)`;
1019
- };
1020
-
1021
- const configStartupEnabled = (s: SettingsFile) => {
1022
- if (configWritesEnabled(s)) {
1023
- s.quietStartup = true;
1024
- s.clearOnStart = true;
1025
- saveSettings(s);
1026
- }
1027
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
1028
- };
1029
-
1030
- const reapply = (
1031
- pi: ExtensionAPI,
1032
- ctx: ExtensionContext,
1033
- s: SettingsFile | null,
1034
- msg: string,
1035
- ) => {
1036
- if (!s) {
1037
- ctx.ui.notify(
1038
- "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1039
- "error",
1040
- );
1041
- return;
1042
- }
1043
- let persistResult: PersistResult = "skipped";
1044
- if (configWritesEnabled(s)) {
1045
- s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1046
- persistResult = saveSettings(s) ? "saved" : "failed";
1047
- }
1048
- active?.dispose();
1049
- active = undefined;
1050
- apply(pi, ctx, "none");
1051
- ctx.ui.notify(withPersistenceNote(msg, s, persistResult), "info");
1052
- };
1053
-
1054
- pi.on("session_before_switch", (event, _ctx) => {
1055
- if (event.reason === "resume") {
1056
- isResuming = true;
1057
- }
1058
- });
1059
-
1060
- pi.on("session_start", (event, ctx) => {
1061
- const s = readSettings(settingsPath);
1062
- if (!s) {
1063
- ctx.ui.notify(
1064
- "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created and a fresh default restored.",
1065
- "error",
1066
- );
1067
- return;
1068
- }
1069
- const h = getCCHeaderConfig(s);
1070
- state = stateFromConfig(h);
1071
- if (state.disabled) return;
1072
-
1073
- if (state.quoteMode && state.sloganOn) {
1074
- const quote = getRandomQuote(ctx);
1075
- if (quote) {
1076
- state.slogan = quote;
1077
- if (configWritesEnabled(s)) {
1078
- s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1079
- saveSettings(s);
1080
- }
1081
- }
1082
- }
1083
-
1084
- configStartupEnabled(s);
1085
- invalidateStats();
1086
- framesDirty = true;
1087
- recomputeFrames();
1088
- const skipAnimation =
1089
- event.reason === "reload" ||
1090
- isResuming ||
1091
- (event.reason === "startup" &&
1092
- (process.argv.includes("-r") ||
1093
- process.argv.includes("--resume") ||
1094
- process.argv.includes("--session")));
1095
- if (isResuming) isResuming = false;
1096
- setTimeout(() => apply(pi, ctx, "none", skipAnimation), 0);
1097
- });
1098
-
1099
- /* ── /pch command ── */
1100
- pi.registerCommand("pch", {
1101
- description:
1102
- "pi-cc-header control: --tg (toggle enable/disable), --c <color> (logo color), --i (IBM stripes), --m (Minecraft), --sp <ms> (speed), --v [all|pi|off] (version color), --ps (pkg skills), --s [text|-c [code]|-d|-quote] (slogan, -c sets slogan color), --logo (custom ASCII logo), --df (defaults), --cl (clear config), --ml (toggle model/thinking line), --h (help)",
1103
- handler: async (args, ctx) => {
1104
- const s = readSettings(settingsPath);
1105
- if (!s) {
1106
- ctx.ui.notify(
1107
- "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1108
- "error",
1109
- );
1110
- return;
1111
- }
1112
-
1113
- // Parse flags
1114
- const argv = args?.trim().split(/\s+/) ?? [];
1115
- if (argv.length === 0 || argv[0] === "--h" || argv[0] === "-h") {
1116
- ctx.ui.notify(
1117
- `pch flags:
1118
- --tg Toggle header enable/disable (next session)
1119
- --c <code> Logo color (c/a/r/o/y/g/w/b/p) | no arg = show current
1120
- --i Toggle IBM stripes
1121
- --m Toggle Minecraft gradient
1122
- --sp [ms] Animation speed (25/50/75/100) | no arg = show current
1123
- --v [all|pi|off] Version label color | no arg = cycle
1124
- --ps Toggle pkg skills visibility
1125
- --s [txt|-c [code]|-d|-quote] Slogan: set text / toggle on-off / -c slogan color (codes like --c) / -d delete / -quote random quote
1126
- --logo [l1|l2|-d] Custom ASCII logo lines (pipe-separated) | -d = restore built-in
1127
- --df Reset to developer defaults
1128
- --cl Clear all config (for uninstall)
1129
- --ml Toggle model/thinking line
1130
- --h Show this help`,
1131
- "info",
1132
- );
1133
- return;
1134
- }
1135
-
1136
- // Find first flag
1137
- const flag = argv[0];
1138
- const flagArg = argv[1];
1139
-
1140
- const doUpdate = (
1141
- updater: (st: CCHeaderState) => string | null,
1142
- skipFrames = false,
1143
- ) => {
1144
- updateState(
1145
- ctx,
1146
- (msg) => reapply(pi, ctx, readSettings(settingsPath), msg),
1147
- updater,
1148
- skipFrames,
1149
- );
1150
- };
1151
-
1152
- switch (flag) {
1153
- case "--tg": {
1154
- const h = getCCHeaderConfig(s);
1155
- if (state.disabled) {
1156
- state.disabled = false;
1157
- h.disabled = false;
1158
- s.ccHeader = h;
1159
- invalidateStats();
1160
- configStartupEnabled(s);
1161
- reapply(
1162
- pi,
1163
- ctx,
1164
- s,
1165
- configWritesEnabled(s)
1166
- ? "pi-cc-header: ENABLED"
1167
- : "pi-cc-header: ENABLED for this session only",
720
+ ? formatQuoteAuthor(paddedAuthor, state.sloganColorKey)
721
+ : `${paddedAuthor}`,
1168
722
  );
1169
723
  } else {
1170
- state.disabled = true;
1171
- h.disabled = true;
1172
- active?.dispose();
1173
- active = undefined;
1174
- ctx.ui.setHeader(undefined);
1175
- if (configWritesEnabled(s)) {
1176
- s.ccHeader = h;
1177
- s.quietStartup = false;
1178
- s.clearOnStart = false;
1179
- const saved = saveSettings(s);
1180
- const persistResult: PersistResult = saved ? "saved" : "failed";
1181
- ctx.ui.notify(
1182
- withPersistenceNote(
1183
- "pi-cc-header: DISABLED. Takes effect next session. /pch --tg to re-enable.",
1184
- s,
1185
- persistResult,
1186
- ),
1187
- "info",
1188
- );
1189
- } else {
1190
- ctx.ui.notify(
1191
- "pi-cc-header: DISABLED for this session only. Config writes are disabled; /pch --tg to re-enable.",
1192
- "info",
1193
- );
1194
- }
1195
- }
1196
- return;
1197
- }
1198
-
1199
- case "--c": {
1200
- if (!flagArg) {
1201
- ctx.ui.notify(
1202
- `Header color: ${state.logoColorKey} (${COLOR_NAMES[state.logoColorKey]}). Available: ${Object.entries(COLOR_NAMES)
1203
- .map(([k, n]) => `${k}=${n}`)
1204
- .join(" ")}`,
1205
- "info",
1206
- );
1207
- return;
1208
- }
1209
- doUpdate((st) => {
1210
- if (!CMAP[flagArg]) {
1211
- ctx.ui.notify(
1212
- `Invalid color: "${flagArg}". Available: ${Object.keys(CMAP).join(" ")}`,
1213
- "error",
1214
- );
1215
- return null;
1216
- }
1217
- st.logoColorKey = flagArg;
1218
- return `Color: ${flagArg}`;
1219
- });
1220
- return;
1221
- }
1222
-
1223
- case "--i": {
1224
- doUpdate((st) => {
1225
- st.stripeEnabled = !st.stripeEnabled;
1226
- return `IBM-style: ${st.stripeEnabled ? "ON" : "OFF"}`;
1227
- });
1228
- return;
1229
- }
1230
-
1231
- case "--m": {
1232
- doUpdate((st) => {
1233
- st.gradientOn = !st.gradientOn;
1234
- return `Minecraft-style: ${st.gradientOn ? "ON" : "OFF"}`;
1235
- });
1236
- return;
1237
- }
1238
-
1239
- case "--sp": {
1240
- if (!flagArg) {
1241
- ctx.ui.notify(
1242
- `Animation speed: ${state.logoInterval}ms. Available: ${SPEEDS.join(" ")}`,
1243
- "info",
1244
- );
1245
- return;
1246
- }
1247
- const n = Number(flagArg);
1248
- if (!(SPEEDS as readonly number[]).includes(n)) {
1249
- ctx.ui.notify(
1250
- `Invalid speed: "${n}". Available: ${SPEEDS.join(" ")}`,
1251
- "error",
1252
- );
1253
- return;
1254
- }
1255
- doUpdate((st) => {
1256
- st.logoInterval = n as (typeof SPEEDS)[number];
1257
- return `Animation speed: ${st.logoInterval}ms`;
1258
- });
1259
- return;
1260
- }
1261
-
1262
- case "--v": {
1263
- if (flagArg) {
1264
- const v = flagArg.trim();
1265
- if (!["all", "pi", "off"].includes(v)) {
1266
- ctx.ui.notify(
1267
- `Invalid value: "${v}". Available: all, pi, off.`,
1268
- "error",
1269
- );
1270
- return;
1271
- }
1272
- doUpdate(
1273
- (st) => {
1274
- st.versionColored = v === "all" ? 2 : v === "pi" ? 1 : 0;
1275
- return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1276
- },
1277
- true,
1278
- );
1279
- return;
1280
- }
1281
- doUpdate(
1282
- (st) => {
1283
- st.versionColored = (st.versionColored + 1) % 3;
1284
- return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1285
- },
1286
- true,
1287
- );
1288
- return;
1289
- }
1290
-
1291
- case "--ps": {
1292
- doUpdate((st) => {
1293
- st.showPkgSkills = !st.showPkgSkills;
1294
- return `Pkg skills: ${st.showPkgSkills ? "VISIBLE" : "HIDDEN"}`;
1295
- });
1296
- return;
1297
- }
1298
-
1299
- case "--s": {
1300
- doUpdate((st) => {
1301
- if (!flagArg) {
1302
- if (!st.slogan) {
1303
- ctx.ui.notify(
1304
- "Command unavailable: no slogan set. Use /pch --s <text> to set one.",
1305
- "error",
1306
- );
1307
- return null;
1308
- }
1309
- st.sloganOn = !st.sloganOn;
1310
- return st.sloganOn ? "Slogan: ON" : "Slogan: OFF";
1311
- }
1312
- if (flagArg === "-c") {
1313
- if (argv[2]) {
1314
- const c = argv[2].trim();
1315
- if (!CMAP[c]) {
1316
- ctx.ui.notify(`Invalid slogan color: "${c}". Available: c a r o y g w b p`, "error");
1317
- return null;
1318
- }
1319
- st.sloganColorKey = c;
1320
- st.sloganColor = true;
1321
- return `Slogan color: ${c} (${COLOR_NAMES[c]})`;
1322
- }
1323
- st.sloganColor = !st.sloganColor;
1324
- return st.sloganColor ? `Slogan color: ${state.sloganColorKey} (${COLOR_NAMES[state.sloganColorKey]})` : "Slogan color: OFF";
1325
- }
1326
- if (flagArg === "-d") {
1327
- st.slogan = "";
1328
- st.sloganOn = false;
1329
- st.quoteMode = false;
1330
- return "Slogan: deleted";
1331
- }
1332
- if (flagArg === "-quote") {
1333
- const quote = getRandomQuote(ctx);
1334
- if (!quote) {
1335
- ctx.ui.notify(
1336
- "No quotes found. Create quotes.json in project root or package dir.",
1337
- "error",
1338
- );
1339
- return null;
1340
- }
1341
- st.slogan = quote;
1342
- st.sloganOn = true;
1343
- st.quoteMode = true;
1344
- return `Slogan (quote): ${quote.replace(/\n/g, " ")}`;
1345
- }
1346
- const text = flagArg.trim();
1347
- if (!text) {
1348
- ctx.ui.notify(
1349
- `Invalid slogan: "". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1350
- "error",
1351
- );
1352
- return null;
1353
- }
1354
- if (text.length > MAX_SLOGAN_LENGTH) {
1355
- ctx.ui.notify(
1356
- `Invalid slogan: "${text}". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1357
- "error",
1358
- );
1359
- return null;
1360
- }
1361
- st.slogan = text;
1362
- st.sloganOn = true;
1363
- st.quoteMode = false;
1364
- return `Slogan: ${text}`;
1365
- });
1366
- return;
1367
- }
1368
-
1369
- case "--df": {
1370
- state = { ...DEFAULT_STATE };
1371
- framesDirty = true;
1372
- recomputeFrames();
1373
- invalidateStats();
1374
- const s2 = readSettings(settingsPath);
1375
- if (!s2) {
1376
- ctx.ui.notify(
1377
- "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1378
- "error",
1379
- );
1380
- return;
1381
- }
1382
- reapply(pi, ctx, s2, "Reset to developer defaults");
1383
- return;
1384
- }
1385
-
1386
- case "--cl": {
1387
- if (!configWritesEnabled(s)) {
1388
- ctx.ui.notify(
1389
- "pi-cc-header: /pch --cl is unavailable when ccHeader.readOnlyConfig=true. Remove the config declaratively, then uninstall the package.",
1390
- "info",
1391
- );
1392
- return;
1393
- }
1394
- delete s.ccHeader;
1395
- delete s.quietStartup;
1396
- delete s.clearOnStart;
1397
- const persisted = saveSettings(s);
1398
- state = { ...DEFAULT_STATE, disabled: true };
1399
- active?.dispose();
1400
- active = undefined;
1401
- ctx.ui.setHeader(undefined);
1402
- ctx.ui.notify(
1403
- persisted
1404
- ? "pi-cc-header Config: cleared. You can now uninstall the package."
1405
- : "pi-cc-header Config: cleared for this session only. Could not save settings.json.",
1406
- "info",
1407
- );
1408
- return;
1409
- }
1410
-
1411
- case "--ml": {
1412
- doUpdate((st) => {
1413
- st.showModelLine = !st.showModelLine;
1414
- return `Model/thinking line: ${st.showModelLine ? "ON" : "OFF"}`;
1415
- }, true);
1416
- return;
1417
- }
1418
-
1419
- case "--logo": {
1420
- // /pch --logo -d → restore built-in Pi logo
1421
- // /pch --logo line1 | line2 → set custom ASCII art (pipe-separated lines)
1422
- if (flagArg === "-d") {
1423
- doUpdate((st) => { st.customLogoLines = null; return "Logo: restored to built-in"; });
1424
- return;
1425
- }
1426
- const raw = argv.slice(1).join(" ");
1427
- if (!raw.trim()) {
1428
- ctx.ui.notify(
1429
- state.customLogoLines
1430
- ? `Custom logo: ${state.customLogoLines.length} lines set. /pch --logo -d to restore built-in.`
1431
- : "No custom logo set. Use /pch --logo line1 | line2 | ... to set one.",
1432
- "info",
724
+ quoteLineWidth = visibleWidth(sloganText);
725
+ rows.push(
726
+ state.sloganColor
727
+ ? `[${CMAP[state.sloganColorKey]}m${sloganText}`
728
+ : muted(`${sloganText}`),
1433
729
  );
1434
- return;
1435
730
  }
1436
- const lines = raw.split("|").map((l) => l.trim());
1437
- doUpdate((st) => { st.customLogoLines = lines; return `Logo: ${lines.length}-line custom ASCII set`; });
1438
- return;
1439
- }
1440
-
1441
- default: {
1442
- ctx.ui.notify(
1443
- `Unknown flag: ${flag}. Use /pch --h for help.`,
1444
- "error",
1445
- );
1446
- return;
1447
- }
1448
- }
1449
- },
1450
- });
1451
- }
1452
-
731
+ }
732
+ } else {
733
+ rows.push(muted(this.stats.agents ? `${this.stats.agents} · ${cwd}` : cwd));
734
+ }
735
+
736
+ infoStrings = rows;
737
+ this.cachedInfoRows = Object.fromEntries(rows.map((r, i) => [i, r]));
738
+ this.cachedInfoWidth = width;
739
+ }
740
+
741
+ // Logo rows 1..5 (y=2..6) paired with info rows 0..3 at indices 2..5
742
+ const LOGO_INFO_MAP: Record<number, number> = { 2: 0, 3: 1, 4: 2, 5: 3 };
743
+
744
+ // Build combined logo+info lines
745
+ const combinedLines: string[] = [];
746
+ for (let i = 1; i < logoLines.length - 1; i++) {
747
+ const infoIdx = LOGO_INFO_MAP[i];
748
+ const right = infoIdx != null ? infoStrings[infoIdx] : "";
749
+ combinedLines.push(padRight(logoLines[i], logoWidth) + right);
750
+ }
751
+
752
+ const maxLineW = Math.max(...combinedLines.map((s) => visibleWidth(s)));
753
+ const leftPad = Math.max(0, Math.floor((width - maxLineW) / 2));
754
+
755
+ const lines: string[] = [];
756
+ for (const cl of combinedLines) {
757
+ lines.push(" ".repeat(leftPad) + truncateToWidth(cl, width - leftPad, ""));
758
+ }
759
+
760
+ // Remaining info rows (model, slogan, cwd) centered independently
761
+ const center = (text: string, w: number): string => {
762
+ const vw = visibleWidth(text);
763
+ const pad = Math.max(0, Math.floor((w - vw) / 2));
764
+ return " ".repeat(pad) + text + " ".repeat(Math.max(0, w - pad - vw));
765
+ };
766
+ for (let i = 4; i < infoStrings.length; i++) {
767
+ const row = infoStrings[i];
768
+ lines.push(
769
+ row === "" ? "" : center(truncateToWidth(row, width, ""), width),
770
+ );
771
+ }
772
+ return lines;
773
+ }
774
+
775
+ invalidate(): void {}
776
+ reapply(): void {
777
+ this.cachedInfoRows = null;
778
+ this.tui.requestRender();
779
+ }
780
+ dispose(): void {
781
+ if (this.timer != null) clearTimeout(this.timer);
782
+ }
783
+ }
784
+
785
+ /* ── Mount ── */
786
+ let active: PiHeader | undefined;
787
+ let isResuming = false;
788
+
789
+ function apply(
790
+ pi: ExtensionAPI,
791
+ ctx: ExtensionContext,
792
+ clearMode: "full" | "viewport" | "none",
793
+ skipAnimation: boolean = false,
794
+ ) {
795
+ if (ctx.mode !== "tui") return;
796
+ if (clearMode === "full") {
797
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
798
+ } else if (clearMode === "viewport") {
799
+ process.stdout.write("\x1b[2J");
800
+ }
801
+ ctx.ui.setHeader((tui) => {
802
+ active?.dispose();
803
+ active = new PiHeader(pi, ctx, tui, skipAnimation);
804
+ return active;
805
+ });
806
+ }
807
+
808
+ /* ── State <-> config serialization ── */
809
+ export const pick = <T>(
810
+ val: unknown,
811
+ guard: (v: unknown) => boolean,
812
+ fallback: T,
813
+ ): T => (guard(val) ? (val as T) : fallback);
814
+
815
+ export function stateFromConfig(h: Record<string, any>): CCHeaderState {
816
+ return {
817
+ logoColorKey: pick(
818
+ h.color,
819
+ (v) => !!CMAP[v as string],
820
+ DEFAULT_STATE.logoColorKey,
821
+ ),
822
+ versionColored: pick(
823
+ h.ver,
824
+ (v) => typeof v === "number",
825
+ DEFAULT_STATE.versionColored,
826
+ ),
827
+ gradientOn: pick(
828
+ h.grad,
829
+ (v) => typeof v === "boolean",
830
+ DEFAULT_STATE.gradientOn,
831
+ ),
832
+ stripeEnabled: pick(
833
+ h.lines,
834
+ (v) => typeof v === "boolean",
835
+ DEFAULT_STATE.stripeEnabled,
836
+ ),
837
+ showPkgSkills: pick(
838
+ h.pkg,
839
+ (v) => typeof v === "boolean",
840
+ DEFAULT_STATE.showPkgSkills,
841
+ ),
842
+ logoInterval: pick(
843
+ h.speed,
844
+ (v) => typeof v === "number" && (SPEEDS as readonly number[]).includes(v),
845
+ DEFAULT_STATE.logoInterval,
846
+ ),
847
+ slogan: pick(
848
+ h.slogan,
849
+ (v) => typeof v === "string" && v.length <= MAX_SLOGAN_LENGTH,
850
+ DEFAULT_STATE.slogan,
851
+ ),
852
+ sloganOn: pick(
853
+ h.sloganOn,
854
+ (v) => typeof v === "boolean",
855
+ DEFAULT_STATE.sloganOn,
856
+ ),
857
+ sloganColor: pick(
858
+ h.sloganColor,
859
+ (v) => typeof v === "boolean",
860
+ DEFAULT_STATE.sloganColor,
861
+ ),
862
+ sloganColorKey: pick(
863
+ h.sloganColorKey ?? h.sloganColorCode,
864
+ (v) => !!CMAP[v as string],
865
+ DEFAULT_STATE.sloganColorKey,
866
+ ),
867
+ quoteMode: pick(
868
+ (h.quoteMode ?? h.stoicMode),
869
+ (v) => typeof v === "boolean",
870
+ DEFAULT_STATE.quoteMode,
871
+ ),
872
+ disabled: pick(
873
+ h.disabled,
874
+ (v) => typeof v === "boolean",
875
+ DEFAULT_STATE.disabled,
876
+ ),
877
+ showModelLine: pick(
878
+ h.showModelLine,
879
+ (v) => typeof v === "boolean",
880
+ DEFAULT_STATE.showModelLine,
881
+ ),
882
+ customLogoLines: pick(
883
+ h.customLogo,
884
+ (v) => Array.isArray(v) && v.every((l) => typeof l === "string"),
885
+ DEFAULT_STATE.customLogoLines,
886
+ ),
887
+ };
888
+ }
889
+
890
+ function stateToConfig(): Record<string, any> {
891
+ return {
892
+ color: state.logoColorKey,
893
+ ver: state.versionColored,
894
+ grad: state.gradientOn,
895
+ lines: state.stripeEnabled,
896
+ pkg: state.showPkgSkills,
897
+ speed: state.logoInterval,
898
+ slogan: state.slogan,
899
+ sloganOn: state.sloganOn,
900
+ sloganColor: state.sloganColor,
901
+ sloganColorKey: state.sloganColorKey,
902
+ quoteMode: state.quoteMode,
903
+ disabled: state.disabled,
904
+ showModelLine: state.showModelLine,
905
+ customLogo: state.customLogoLines,
906
+ };
907
+ }
908
+
909
+ function getCCHeaderConfig(
910
+ settings: SettingsFile | null | undefined,
911
+ ): CCHeaderConfig {
912
+ const ccHeader = settings?.ccHeader;
913
+ return ccHeader && typeof ccHeader === "object" && !Array.isArray(ccHeader)
914
+ ? ccHeader
915
+ : {};
916
+ }
917
+
918
+ export function configWritesEnabled(
919
+ settings: SettingsFile | null | undefined,
920
+ ): boolean {
921
+ return getCCHeaderConfig(settings).readOnlyConfig !== true;
922
+ }
923
+
924
+ type PersistResult = "saved" | "skipped" | "failed";
925
+
926
+ function isReadonlyWriteError(error: unknown): boolean {
927
+ if (!error || typeof error !== "object" || !("code" in error)) return false;
928
+ return ["EROFS", "EACCES", "EPERM"].includes(
929
+ String((error as NodeJS.ErrnoException).code),
930
+ );
931
+ }
932
+
933
+ /* ── Config update ── */
934
+ function updateState(
935
+ ctx: ExtensionContext,
936
+ applyAndPersist: (msg: string) => void,
937
+ updater: (s: CCHeaderState) => string | null,
938
+ skipFrames: boolean = false,
939
+ ): void {
940
+ if (state.disabled) {
941
+ ctx.ui.notify(
942
+ "Command unavailable: pi-cc-header disabled. Use /pch --tg to enable.",
943
+ "info",
944
+ );
945
+ return;
946
+ }
947
+
948
+ const prevColor = state.logoColorKey;
949
+ const prevGrad = state.gradientOn;
950
+ const prevStripe = state.stripeEnabled;
951
+
952
+ const msg = updater(state);
953
+ if (msg === null) return;
954
+
955
+ if (
956
+ (!skipFrames && state.logoColorKey !== prevColor) ||
957
+ state.gradientOn !== prevGrad ||
958
+ state.stripeEnabled !== prevStripe
959
+ ) {
960
+ framesDirty = true;
961
+ }
962
+ if (framesDirty) recomputeFrames();
963
+
964
+ applyAndPersist(msg);
965
+ }
966
+
967
+ function emptySettings(): SettingsFile {
968
+ return {};
969
+ }
970
+
971
+ function parseSettingsFile(settingsPath: string): SettingsFile {
972
+ const content = readFileSync(settingsPath, "utf-8");
973
+ try {
974
+ const parsed = JSON.parse(content);
975
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
976
+ throw new Error("pi-cc-header: settings.json must contain an object");
977
+ }
978
+ return parsed;
979
+ } catch (error) {
980
+ throw new Error("pi-cc-header: invalid settings.json", { cause: error });
981
+ }
982
+ }
983
+
984
+ function backupCorruptedSettings(settingsPath: string): void {
985
+ try {
986
+ if (!existsSync(settingsPath)) return;
987
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
988
+ const bak = settingsPath.replace(/\.json$/, `.bak.${ts}.json`);
989
+ copyFileSync(settingsPath, bak);
990
+ console.error("pi-cc-header: corrupted settings.json backed up to", bak);
991
+ } catch {
992
+ console.error("pi-cc-header: failed to read or back up settings.json");
993
+ }
994
+ }
995
+
996
+ function restoreDefaultSettingsFile(settingsPath: string): void {
997
+ try {
998
+ mkdirSync(dirname(settingsPath), { recursive: true });
999
+ writeFileSync(settingsPath, "{\n}\n", "utf-8");
1000
+ } catch {
1001
+ console.error("pi-cc-header: failed to restore default settings.json");
1002
+ }
1003
+ }
1004
+
1005
+ function readSettings(settingsPath: string): SettingsFile | null {
1006
+ if (!existsSync(settingsPath)) return emptySettings();
1007
+
1008
+ try {
1009
+ return parseSettingsFile(settingsPath);
1010
+ } catch {
1011
+ backupCorruptedSettings(settingsPath);
1012
+ restoreDefaultSettingsFile(settingsPath);
1013
+ return null;
1014
+ }
1015
+ }
1016
+
1017
+ /* ── Entry ── */
1018
+ export default function (pi: ExtensionAPI) {
1019
+ const settingsPath = getRuntimePaths().settingsPath;
1020
+
1021
+ const saveSettings = (s: SettingsFile): boolean => {
1022
+ try {
1023
+ mkdirSync(dirname(settingsPath), { recursive: true });
1024
+ writeFileSync(settingsPath, `${JSON.stringify(s, null, 2)}\n`, "utf-8");
1025
+ return true;
1026
+ } catch (error) {
1027
+ if (isReadonlyWriteError(error)) return false;
1028
+ console.error("pi-cc-header: failed to write settings.json");
1029
+ return false;
1030
+ }
1031
+ };
1032
+
1033
+ const withPersistenceNote = (
1034
+ msg: string,
1035
+ settings: SettingsFile,
1036
+ persistResult: PersistResult,
1037
+ ) => {
1038
+ if (persistResult === "saved") return msg;
1039
+ if (persistResult === "skipped") {
1040
+ return `${msg} (session only; ccHeader.readOnlyConfig=true)`;
1041
+ }
1042
+ return configWritesEnabled(settings)
1043
+ ? `${msg} (not saved: settings.json is not writable)`
1044
+ : `${msg} (session only; ccHeader.readOnlyConfig=true)`;
1045
+ };
1046
+
1047
+ const configStartupEnabled = (s: SettingsFile) => {
1048
+ if (configWritesEnabled(s)) {
1049
+ s.quietStartup = true;
1050
+ s.clearOnStart = true;
1051
+ saveSettings(s);
1052
+ }
1053
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
1054
+ };
1055
+
1056
+ const reapply = (
1057
+ pi: ExtensionAPI,
1058
+ ctx: ExtensionContext,
1059
+ s: SettingsFile | null,
1060
+ msg: string,
1061
+ ) => {
1062
+ if (!s) {
1063
+ ctx.ui.notify(
1064
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1065
+ "error",
1066
+ );
1067
+ return;
1068
+ }
1069
+ let persistResult: PersistResult = "skipped";
1070
+ if (configWritesEnabled(s)) {
1071
+ s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1072
+ persistResult = saveSettings(s) ? "saved" : "failed";
1073
+ }
1074
+ active?.dispose();
1075
+ active = undefined;
1076
+ apply(pi, ctx, "none");
1077
+ ctx.ui.notify(withPersistenceNote(msg, s, persistResult), "info");
1078
+ };
1079
+
1080
+ pi.on("session_before_switch", (event, _ctx) => {
1081
+ if (event.reason === "resume") {
1082
+ isResuming = true;
1083
+ }
1084
+ });
1085
+
1086
+ pi.on("session_start", (event, ctx) => {
1087
+ const s = readSettings(settingsPath);
1088
+ if (!s) {
1089
+ ctx.ui.notify(
1090
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created and a fresh default restored.",
1091
+ "error",
1092
+ );
1093
+ return;
1094
+ }
1095
+ const h = getCCHeaderConfig(s);
1096
+ state = stateFromConfig(h);
1097
+ if (state.disabled) return;
1098
+
1099
+ if (state.quoteMode && state.sloganOn) {
1100
+ const quote = getRandomQuote(ctx);
1101
+ if (quote) {
1102
+ state.slogan = quote;
1103
+ if (configWritesEnabled(s)) {
1104
+ s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1105
+ saveSettings(s);
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ configStartupEnabled(s);
1111
+ invalidateStats();
1112
+ framesDirty = true;
1113
+ recomputeFrames();
1114
+ const skipAnimation =
1115
+ event.reason === "reload" ||
1116
+ isResuming ||
1117
+ (event.reason === "startup" &&
1118
+ (process.argv.includes("-r") ||
1119
+ process.argv.includes("--resume") ||
1120
+ process.argv.includes("--session")));
1121
+ if (isResuming) isResuming = false;
1122
+ setTimeout(() => apply(pi, ctx, "none", skipAnimation), 0);
1123
+ });
1124
+
1125
+ /* ── /pch command ── */
1126
+ pi.registerCommand("pch", {
1127
+ description:
1128
+ "pi-cc-header control: --tg (toggle enable/disable), --c <color> (logo color), --i (IBM stripes), --m (Minecraft), --sp <ms> (speed), --v [all|pi|off] (version color), --ps (pkg skills), --s [text|-c [code]|-d|-quote] (slogan, -c sets slogan color), --logo (custom ASCII logo), --df (defaults), --cl (clear config), --ml (toggle model/thinking line), --h (help)",
1129
+ handler: async (args, ctx) => {
1130
+ const s = readSettings(settingsPath);
1131
+ if (!s) {
1132
+ ctx.ui.notify(
1133
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1134
+ "error",
1135
+ );
1136
+ return;
1137
+ }
1138
+
1139
+ // Parse flags
1140
+ const argv = args?.trim().split(/\s+/) ?? [];
1141
+ if (argv.length === 0 || argv[0] === "--h" || argv[0] === "-h") {
1142
+ ctx.ui.notify(
1143
+ `pch flags:
1144
+ --tg Toggle header enable/disable (next session)
1145
+ --c <code> Logo color (c/a/r/o/y/g/w/b/p) | no arg = show current
1146
+ --i Toggle IBM stripes
1147
+ --m Toggle Minecraft gradient
1148
+ --sp [ms] Animation speed (25/50/75/100) | no arg = show current
1149
+ --v [all|pi|off] Version label color | no arg = cycle
1150
+ --ps Toggle pkg skills visibility
1151
+ --s [txt|-c [code]|-d|-quote] Slogan: set text / toggle on-off / -c slogan color (codes like --c) / -d delete / -quote random quote
1152
+ --logo [l1|l2|-d] Custom ASCII logo lines (pipe-separated) | -d = restore built-in
1153
+ --df Reset to developer defaults
1154
+ --cl Clear all config (for uninstall)
1155
+ --ml Toggle model/thinking line
1156
+ --h Show this help`,
1157
+ "info",
1158
+ );
1159
+ return;
1160
+ }
1161
+
1162
+ // Find first flag
1163
+ const flag = argv[0];
1164
+ const flagArg = argv[1];
1165
+
1166
+ const doUpdate = (
1167
+ updater: (st: CCHeaderState) => string | null,
1168
+ skipFrames = false,
1169
+ ) => {
1170
+ updateState(
1171
+ ctx,
1172
+ (msg) => reapply(pi, ctx, readSettings(settingsPath), msg),
1173
+ updater,
1174
+ skipFrames,
1175
+ );
1176
+ };
1177
+
1178
+ switch (flag) {
1179
+ case "--tg": {
1180
+ const h = getCCHeaderConfig(s);
1181
+ if (state.disabled) {
1182
+ state.disabled = false;
1183
+ h.disabled = false;
1184
+ s.ccHeader = h;
1185
+ invalidateStats();
1186
+ configStartupEnabled(s);
1187
+ reapply(
1188
+ pi,
1189
+ ctx,
1190
+ s,
1191
+ configWritesEnabled(s)
1192
+ ? "pi-cc-header: ENABLED"
1193
+ : "pi-cc-header: ENABLED for this session only",
1194
+ );
1195
+ } else {
1196
+ state.disabled = true;
1197
+ h.disabled = true;
1198
+ active?.dispose();
1199
+ active = undefined;
1200
+ ctx.ui.setHeader(undefined);
1201
+ if (configWritesEnabled(s)) {
1202
+ s.ccHeader = h;
1203
+ s.quietStartup = false;
1204
+ s.clearOnStart = false;
1205
+ const saved = saveSettings(s);
1206
+ const persistResult: PersistResult = saved ? "saved" : "failed";
1207
+ ctx.ui.notify(
1208
+ withPersistenceNote(
1209
+ "pi-cc-header: DISABLED. Takes effect next session. /pch --tg to re-enable.",
1210
+ s,
1211
+ persistResult,
1212
+ ),
1213
+ "info",
1214
+ );
1215
+ } else {
1216
+ ctx.ui.notify(
1217
+ "pi-cc-header: DISABLED for this session only. Config writes are disabled; /pch --tg to re-enable.",
1218
+ "info",
1219
+ );
1220
+ }
1221
+ }
1222
+ return;
1223
+ }
1224
+
1225
+ case "--c": {
1226
+ if (!flagArg) {
1227
+ ctx.ui.notify(
1228
+ `Header color: ${state.logoColorKey} (${COLOR_NAMES[state.logoColorKey]}). Available: ${Object.entries(COLOR_NAMES)
1229
+ .map(([k, n]) => `${k}=${n}`)
1230
+ .join(" ")}`,
1231
+ "info",
1232
+ );
1233
+ return;
1234
+ }
1235
+ doUpdate((st) => {
1236
+ if (!CMAP[flagArg]) {
1237
+ ctx.ui.notify(
1238
+ `Invalid color: "${flagArg}". Available: ${Object.keys(CMAP).join(" ")}`,
1239
+ "error",
1240
+ );
1241
+ return null;
1242
+ }
1243
+ st.logoColorKey = flagArg;
1244
+ return `Color: ${flagArg}`;
1245
+ });
1246
+ return;
1247
+ }
1248
+
1249
+ case "--i": {
1250
+ doUpdate((st) => {
1251
+ st.stripeEnabled = !st.stripeEnabled;
1252
+ return `IBM-style: ${st.stripeEnabled ? "ON" : "OFF"}`;
1253
+ });
1254
+ return;
1255
+ }
1256
+
1257
+ case "--m": {
1258
+ doUpdate((st) => {
1259
+ st.gradientOn = !st.gradientOn;
1260
+ return `Minecraft-style: ${st.gradientOn ? "ON" : "OFF"}`;
1261
+ });
1262
+ return;
1263
+ }
1264
+
1265
+ case "--sp": {
1266
+ if (!flagArg) {
1267
+ ctx.ui.notify(
1268
+ `Animation speed: ${state.logoInterval}ms. Available: ${SPEEDS.join(" ")}`,
1269
+ "info",
1270
+ );
1271
+ return;
1272
+ }
1273
+ const n = Number(flagArg);
1274
+ if (!(SPEEDS as readonly number[]).includes(n)) {
1275
+ ctx.ui.notify(
1276
+ `Invalid speed: "${n}". Available: ${SPEEDS.join(" ")}`,
1277
+ "error",
1278
+ );
1279
+ return;
1280
+ }
1281
+ doUpdate((st) => {
1282
+ st.logoInterval = n as (typeof SPEEDS)[number];
1283
+ return `Animation speed: ${st.logoInterval}ms`;
1284
+ });
1285
+ return;
1286
+ }
1287
+
1288
+ case "--v": {
1289
+ if (flagArg) {
1290
+ const v = flagArg.trim();
1291
+ if (!["all", "pi", "off"].includes(v)) {
1292
+ ctx.ui.notify(
1293
+ `Invalid value: "${v}". Available: all, pi, off.`,
1294
+ "error",
1295
+ );
1296
+ return;
1297
+ }
1298
+ doUpdate(
1299
+ (st) => {
1300
+ st.versionColored = v === "all" ? 2 : v === "pi" ? 1 : 0;
1301
+ return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1302
+ },
1303
+ true,
1304
+ );
1305
+ return;
1306
+ }
1307
+ doUpdate(
1308
+ (st) => {
1309
+ st.versionColored = (st.versionColored + 1) % 3;
1310
+ return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1311
+ },
1312
+ true,
1313
+ );
1314
+ return;
1315
+ }
1316
+
1317
+ case "--ps": {
1318
+ doUpdate((st) => {
1319
+ st.showPkgSkills = !st.showPkgSkills;
1320
+ return `Pkg skills: ${st.showPkgSkills ? "VISIBLE" : "HIDDEN"}`;
1321
+ });
1322
+ return;
1323
+ }
1324
+
1325
+ case "--s": {
1326
+ doUpdate((st) => {
1327
+ if (!flagArg) {
1328
+ if (!st.slogan) {
1329
+ ctx.ui.notify(
1330
+ "Command unavailable: no slogan set. Use /pch --s <text> to set one.",
1331
+ "error",
1332
+ );
1333
+ return null;
1334
+ }
1335
+ st.sloganOn = !st.sloganOn;
1336
+ return st.sloganOn ? "Slogan: ON" : "Slogan: OFF";
1337
+ }
1338
+ if (flagArg === "-c") {
1339
+ if (argv[2]) {
1340
+ const c = argv[2].trim();
1341
+ if (!CMAP[c]) {
1342
+ ctx.ui.notify(`Invalid slogan color: "${c}". Available: c a r o y g w b p`, "error");
1343
+ return null;
1344
+ }
1345
+ st.sloganColorKey = c;
1346
+ st.sloganColor = true;
1347
+ return `Slogan color: ${c} (${COLOR_NAMES[c]})`;
1348
+ }
1349
+ st.sloganColor = !st.sloganColor;
1350
+ return st.sloganColor ? `Slogan color: ${state.sloganColorKey} (${COLOR_NAMES[state.sloganColorKey]})` : "Slogan color: OFF";
1351
+ }
1352
+ if (flagArg === "-d") {
1353
+ st.slogan = "";
1354
+ st.sloganOn = false;
1355
+ st.quoteMode = false;
1356
+ return "Slogan: deleted";
1357
+ }
1358
+ if (flagArg === "-quote") {
1359
+ const quote = getRandomQuote(ctx);
1360
+ if (!quote) {
1361
+ ctx.ui.notify(
1362
+ "No quotes found. Create quotes.json in project root or package dir.",
1363
+ "error",
1364
+ );
1365
+ return null;
1366
+ }
1367
+ st.slogan = quote;
1368
+ st.sloganOn = true;
1369
+ st.quoteMode = true;
1370
+ return `Slogan (quote): ${quote.replace(/\n/g, " ")}`;
1371
+ }
1372
+ const text = flagArg.trim();
1373
+ if (!text) {
1374
+ ctx.ui.notify(
1375
+ `Invalid slogan: "". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1376
+ "error",
1377
+ );
1378
+ return null;
1379
+ }
1380
+ if (text.length > MAX_SLOGAN_LENGTH) {
1381
+ ctx.ui.notify(
1382
+ `Invalid slogan: "${text}". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1383
+ "error",
1384
+ );
1385
+ return null;
1386
+ }
1387
+ st.slogan = text;
1388
+ st.sloganOn = true;
1389
+ st.quoteMode = false;
1390
+ return `Slogan: ${text}`;
1391
+ });
1392
+ return;
1393
+ }
1394
+
1395
+ case "--df": {
1396
+ state = { ...DEFAULT_STATE };
1397
+ framesDirty = true;
1398
+ recomputeFrames();
1399
+ invalidateStats();
1400
+ const s2 = readSettings(settingsPath);
1401
+ if (!s2) {
1402
+ ctx.ui.notify(
1403
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1404
+ "error",
1405
+ );
1406
+ return;
1407
+ }
1408
+ reapply(pi, ctx, s2, "Reset to developer defaults");
1409
+ return;
1410
+ }
1411
+
1412
+ case "--cl": {
1413
+ if (!configWritesEnabled(s)) {
1414
+ ctx.ui.notify(
1415
+ "pi-cc-header: /pch --cl is unavailable when ccHeader.readOnlyConfig=true. Remove the config declaratively, then uninstall the package.",
1416
+ "info",
1417
+ );
1418
+ return;
1419
+ }
1420
+ delete s.ccHeader;
1421
+ delete s.quietStartup;
1422
+ delete s.clearOnStart;
1423
+ const persisted = saveSettings(s);
1424
+ state = { ...DEFAULT_STATE, disabled: true };
1425
+ active?.dispose();
1426
+ active = undefined;
1427
+ ctx.ui.setHeader(undefined);
1428
+ ctx.ui.notify(
1429
+ persisted
1430
+ ? "pi-cc-header Config: cleared. You can now uninstall the package."
1431
+ : "pi-cc-header Config: cleared for this session only. Could not save settings.json.",
1432
+ "info",
1433
+ );
1434
+ return;
1435
+ }
1436
+
1437
+ case "--ml": {
1438
+ doUpdate((st) => {
1439
+ st.showModelLine = !st.showModelLine;
1440
+ return `Model/thinking line: ${st.showModelLine ? "ON" : "OFF"}`;
1441
+ }, true);
1442
+ return;
1443
+ }
1444
+
1445
+ case "--logo": {
1446
+ // /pch --logo -d → restore built-in Pi logo
1447
+ // /pch --logo line1 | line2 → set custom ASCII art (pipe-separated lines)
1448
+ if (flagArg === "-d") {
1449
+ doUpdate((st) => { st.customLogoLines = null; return "Logo: restored to built-in"; });
1450
+ return;
1451
+ }
1452
+ const raw = argv.slice(1).join(" ");
1453
+ if (!raw.trim()) {
1454
+ ctx.ui.notify(
1455
+ state.customLogoLines
1456
+ ? `Custom logo: ${state.customLogoLines.length} lines set. /pch --logo -d to restore built-in.`
1457
+ : "No custom logo set. Use /pch --logo line1 | line2 | ... to set one.",
1458
+ "info",
1459
+ );
1460
+ return;
1461
+ }
1462
+ const lines = raw.split("|").map((l) => l.trim());
1463
+ doUpdate((st) => { st.customLogoLines = lines; return `Logo: ${lines.length}-line custom ASCII set`; });
1464
+ return;
1465
+ }
1466
+
1467
+ default: {
1468
+ ctx.ui.notify(
1469
+ `Unknown flag: ${flag}. Use /pch --h for help.`,
1470
+ "error",
1471
+ );
1472
+ return;
1473
+ }
1474
+ }
1475
+ },
1476
+ });
1477
+ }
1478
+