@tenchi4u/pi-cc-header 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1433 @@
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
+
651
+ let infoStrings: string[];
652
+ if (this.cachedInfoRows && this.cachedInfoWidth === width) {
653
+ infoStrings = Object.values(this.cachedInfoRows);
654
+ } else {
655
+ const model = this.ctx.model?.id ?? "Default";
656
+ const effort = this.pi.getThinkingLevel();
657
+ const cwd = formatCwd(this.ctx.cwd);
658
+ const skillText = state.showPkgSkills
659
+ ? `${this.stats.skills}|${this.stats.pkgSkills} skills`
660
+ : `${this.stats.skills} skills`;
661
+ const extText =
662
+ this.stats.extensions.residue > 0
663
+ ? `${this.stats.extensions.installed}(+${this.stats.extensions.residue}) extensions`
664
+ : `${this.stats.extensions.installed} extensions`;
665
+ const statsLine = `${skillText} · ${this.stats.prompts} prompts · ${extText}`;
666
+
667
+ const piText =
668
+ state.versionColored >= 2
669
+ ? `\x1b[${CMAP[state.logoColorKey]}mPi v${VERSION}\x1b[39m`
670
+ : state.versionColored >= 1
671
+ ? `\x1b[${CMAP[state.logoColorKey]}mPi\x1b[39m ${muted(`v${VERSION}`)}`
672
+ : muted(`Pi v${VERSION}`);
673
+
674
+ const modelLine = `${model} · ${effort}${this.stats.agents ? ` | ${this.stats.agents}` : ""}`;
675
+
676
+ const rows: string[] = [piText];
677
+ if (state.sloganOn && state.slogan) {
678
+ const sloganLines = state.slogan.split("\n");
679
+ for (let idx = 0; idx < sloganLines.length; idx++) {
680
+ const line = sloganLines[idx];
681
+ const sloganW = visibleWidth(line);
682
+ const sloganText =
683
+ sloganW > width
684
+ ? truncateToWidth(line, width - 3, "") + "..."
685
+ : line;
686
+
687
+ const isAuthor = idx === sloganLines.length - 1 && sloganLines.length > 1;
688
+ if (isAuthor) {
689
+ // Author in slogan color, regular weight
690
+ rows.push(`\x1b[${CMAP[state.sloganColorKey]}m${sloganText}\x1b[39m`);
691
+ } else {
692
+ rows.push(
693
+ state.sloganColor
694
+ ? `\x1b[1m\x1b[${CMAP[state.sloganColorKey]}m${sloganText}\x1b[39m\x1b[22m`
695
+ : muted(`\x1b[1m${sloganText}\x1b[22m`),
696
+ );
697
+ }
698
+ }
699
+ }
700
+ if (state.showModelLine) rows.push(muted(modelLine));
701
+ rows.push(muted(statsLine));
702
+ if (!state.sloganOn) {
703
+ rows.push(muted(this.stats.agents ? `${this.stats.agents} · ${cwd}` : cwd));
704
+ }
705
+
706
+ infoStrings = rows;
707
+ // Store in cachedInfoRows as index map for cache key reuse
708
+ this.cachedInfoRows = Object.fromEntries(rows.map((r, i) => [i, r]));
709
+ this.cachedInfoWidth = width;
710
+ }
711
+
712
+ const center = (text: string, w: number, offset = 2): string => {
713
+ const vw = visibleWidth(text);
714
+ const pad = Math.max(0, Math.floor((w - vw) / 2) + offset);
715
+ return " ".repeat(pad) + text + " ".repeat(Math.max(0, w - pad - vw));
716
+ };
717
+
718
+ const lines: string[] = [];
719
+ for (let i = 1; i < logoLines.length; i++) {
720
+ lines.push(center(logoLines[i], width, 4));
721
+ }
722
+ for (const row of infoStrings) {
723
+ if (row) {
724
+ lines.push(center(truncateToWidth(row, width, ""), width, 0));
725
+ }
726
+ }
727
+ return lines;
728
+ }
729
+
730
+ invalidate(): void {}
731
+ reapply(): void {
732
+ this.cachedInfoRows = null;
733
+ this.tui.requestRender();
734
+ }
735
+ dispose(): void {
736
+ if (this.timer != null) clearTimeout(this.timer);
737
+ }
738
+ }
739
+
740
+ /* ── Mount ── */
741
+ let active: PiHeader | undefined;
742
+ let isResuming = false;
743
+
744
+ function apply(
745
+ pi: ExtensionAPI,
746
+ ctx: ExtensionContext,
747
+ clearMode: "full" | "viewport" | "none",
748
+ skipAnimation: boolean = false,
749
+ ) {
750
+ if (ctx.mode !== "tui") return;
751
+ if (clearMode === "full") {
752
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
753
+ } else if (clearMode === "viewport") {
754
+ process.stdout.write("\x1b[2J");
755
+ }
756
+ ctx.ui.setHeader((tui) => {
757
+ active?.dispose();
758
+ active = new PiHeader(pi, ctx, tui, skipAnimation);
759
+ return active;
760
+ });
761
+ }
762
+
763
+ /* ── State <-> config serialization ── */
764
+ export const pick = <T>(
765
+ val: unknown,
766
+ guard: (v: unknown) => boolean,
767
+ fallback: T,
768
+ ): T => (guard(val) ? (val as T) : fallback);
769
+
770
+ export function stateFromConfig(h: Record<string, any>): CCHeaderState {
771
+ return {
772
+ logoColorKey: pick(
773
+ h.color,
774
+ (v) => !!CMAP[v as string],
775
+ DEFAULT_STATE.logoColorKey,
776
+ ),
777
+ versionColored: pick(
778
+ h.ver,
779
+ (v) => typeof v === "number",
780
+ DEFAULT_STATE.versionColored,
781
+ ),
782
+ gradientOn: pick(
783
+ h.grad,
784
+ (v) => typeof v === "boolean",
785
+ DEFAULT_STATE.gradientOn,
786
+ ),
787
+ stripeEnabled: pick(
788
+ h.lines,
789
+ (v) => typeof v === "boolean",
790
+ DEFAULT_STATE.stripeEnabled,
791
+ ),
792
+ showPkgSkills: pick(
793
+ h.pkg,
794
+ (v) => typeof v === "boolean",
795
+ DEFAULT_STATE.showPkgSkills,
796
+ ),
797
+ logoInterval: pick(
798
+ h.speed,
799
+ (v) => typeof v === "number" && (SPEEDS as readonly number[]).includes(v),
800
+ DEFAULT_STATE.logoInterval,
801
+ ),
802
+ slogan: pick(
803
+ h.slogan,
804
+ (v) => typeof v === "string" && v.length <= MAX_SLOGAN_LENGTH,
805
+ DEFAULT_STATE.slogan,
806
+ ),
807
+ sloganOn: pick(
808
+ h.sloganOn,
809
+ (v) => typeof v === "boolean",
810
+ DEFAULT_STATE.sloganOn,
811
+ ),
812
+ sloganColor: pick(
813
+ h.sloganColor,
814
+ (v) => typeof v === "boolean",
815
+ DEFAULT_STATE.sloganColor,
816
+ ),
817
+ sloganColorKey: pick(
818
+ h.sloganColorKey ?? h.sloganColorCode,
819
+ (v) => !!CMAP[v as string],
820
+ DEFAULT_STATE.sloganColorKey,
821
+ ),
822
+ quoteMode: pick(
823
+ (h.quoteMode ?? h.stoicMode),
824
+ (v) => typeof v === "boolean",
825
+ DEFAULT_STATE.quoteMode,
826
+ ),
827
+ disabled: pick(
828
+ h.disabled,
829
+ (v) => typeof v === "boolean",
830
+ DEFAULT_STATE.disabled,
831
+ ),
832
+ showModelLine: pick(
833
+ h.showModelLine,
834
+ (v) => typeof v === "boolean",
835
+ DEFAULT_STATE.showModelLine,
836
+ ),
837
+ customLogoLines: pick(
838
+ h.customLogo,
839
+ (v) => Array.isArray(v) && v.every((l) => typeof l === "string"),
840
+ DEFAULT_STATE.customLogoLines,
841
+ ),
842
+ };
843
+ }
844
+
845
+ function stateToConfig(): Record<string, any> {
846
+ return {
847
+ color: state.logoColorKey,
848
+ ver: state.versionColored,
849
+ grad: state.gradientOn,
850
+ lines: state.stripeEnabled,
851
+ pkg: state.showPkgSkills,
852
+ speed: state.logoInterval,
853
+ slogan: state.slogan,
854
+ sloganOn: state.sloganOn,
855
+ sloganColor: state.sloganColor,
856
+ sloganColorKey: state.sloganColorKey,
857
+ quoteMode: state.quoteMode,
858
+ disabled: state.disabled,
859
+ showModelLine: state.showModelLine,
860
+ customLogo: state.customLogoLines,
861
+ };
862
+ }
863
+
864
+ function getCCHeaderConfig(
865
+ settings: SettingsFile | null | undefined,
866
+ ): CCHeaderConfig {
867
+ const ccHeader = settings?.ccHeader;
868
+ return ccHeader && typeof ccHeader === "object" && !Array.isArray(ccHeader)
869
+ ? ccHeader
870
+ : {};
871
+ }
872
+
873
+ export function configWritesEnabled(
874
+ settings: SettingsFile | null | undefined,
875
+ ): boolean {
876
+ return getCCHeaderConfig(settings).readOnlyConfig !== true;
877
+ }
878
+
879
+ type PersistResult = "saved" | "skipped" | "failed";
880
+
881
+ function isReadonlyWriteError(error: unknown): boolean {
882
+ if (!error || typeof error !== "object" || !("code" in error)) return false;
883
+ return ["EROFS", "EACCES", "EPERM"].includes(
884
+ String((error as NodeJS.ErrnoException).code),
885
+ );
886
+ }
887
+
888
+ /* ── Config update ── */
889
+ function updateState(
890
+ ctx: ExtensionContext,
891
+ applyAndPersist: (msg: string) => void,
892
+ updater: (s: CCHeaderState) => string | null,
893
+ skipFrames: boolean = false,
894
+ ): void {
895
+ if (state.disabled) {
896
+ ctx.ui.notify(
897
+ "Command unavailable: pi-cc-header disabled. Use /pch --tg to enable.",
898
+ "info",
899
+ );
900
+ return;
901
+ }
902
+
903
+ const prevColor = state.logoColorKey;
904
+ const prevGrad = state.gradientOn;
905
+ const prevStripe = state.stripeEnabled;
906
+
907
+ const msg = updater(state);
908
+ if (msg === null) return;
909
+
910
+ if (
911
+ (!skipFrames && state.logoColorKey !== prevColor) ||
912
+ state.gradientOn !== prevGrad ||
913
+ state.stripeEnabled !== prevStripe
914
+ ) {
915
+ framesDirty = true;
916
+ }
917
+ if (framesDirty) recomputeFrames();
918
+
919
+ applyAndPersist(msg);
920
+ }
921
+
922
+ function emptySettings(): SettingsFile {
923
+ return {};
924
+ }
925
+
926
+ function parseSettingsFile(settingsPath: string): SettingsFile {
927
+ const content = readFileSync(settingsPath, "utf-8");
928
+ try {
929
+ const parsed = JSON.parse(content);
930
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
931
+ throw new Error("pi-cc-header: settings.json must contain an object");
932
+ }
933
+ return parsed;
934
+ } catch (error) {
935
+ throw new Error("pi-cc-header: invalid settings.json", { cause: error });
936
+ }
937
+ }
938
+
939
+ function backupCorruptedSettings(settingsPath: string): void {
940
+ try {
941
+ if (!existsSync(settingsPath)) return;
942
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
943
+ const bak = settingsPath.replace(/\.json$/, `.bak.${ts}.json`);
944
+ copyFileSync(settingsPath, bak);
945
+ console.error("pi-cc-header: corrupted settings.json backed up to", bak);
946
+ } catch {
947
+ console.error("pi-cc-header: failed to read or back up settings.json");
948
+ }
949
+ }
950
+
951
+ function restoreDefaultSettingsFile(settingsPath: string): void {
952
+ try {
953
+ mkdirSync(dirname(settingsPath), { recursive: true });
954
+ writeFileSync(settingsPath, "{\n}\n", "utf-8");
955
+ } catch {
956
+ console.error("pi-cc-header: failed to restore default settings.json");
957
+ }
958
+ }
959
+
960
+ function readSettings(settingsPath: string): SettingsFile | null {
961
+ if (!existsSync(settingsPath)) return emptySettings();
962
+
963
+ try {
964
+ return parseSettingsFile(settingsPath);
965
+ } catch {
966
+ backupCorruptedSettings(settingsPath);
967
+ restoreDefaultSettingsFile(settingsPath);
968
+ return null;
969
+ }
970
+ }
971
+
972
+ /* ── Entry ── */
973
+ export default function (pi: ExtensionAPI) {
974
+ const settingsPath = getRuntimePaths().settingsPath;
975
+
976
+ const saveSettings = (s: SettingsFile): boolean => {
977
+ try {
978
+ mkdirSync(dirname(settingsPath), { recursive: true });
979
+ writeFileSync(settingsPath, `${JSON.stringify(s, null, 2)}\n`, "utf-8");
980
+ return true;
981
+ } catch (error) {
982
+ if (isReadonlyWriteError(error)) return false;
983
+ console.error("pi-cc-header: failed to write settings.json");
984
+ return false;
985
+ }
986
+ };
987
+
988
+ const withPersistenceNote = (
989
+ msg: string,
990
+ settings: SettingsFile,
991
+ persistResult: PersistResult,
992
+ ) => {
993
+ if (persistResult === "saved") return msg;
994
+ if (persistResult === "skipped") {
995
+ return `${msg} (session only; ccHeader.readOnlyConfig=true)`;
996
+ }
997
+ return configWritesEnabled(settings)
998
+ ? `${msg} (not saved: settings.json is not writable)`
999
+ : `${msg} (session only; ccHeader.readOnlyConfig=true)`;
1000
+ };
1001
+
1002
+ const configStartupEnabled = (s: SettingsFile) => {
1003
+ if (configWritesEnabled(s)) {
1004
+ s.quietStartup = true;
1005
+ s.clearOnStart = true;
1006
+ saveSettings(s);
1007
+ }
1008
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
1009
+ };
1010
+
1011
+ const reapply = (
1012
+ pi: ExtensionAPI,
1013
+ ctx: ExtensionContext,
1014
+ s: SettingsFile | null,
1015
+ msg: string,
1016
+ ) => {
1017
+ if (!s) {
1018
+ ctx.ui.notify(
1019
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1020
+ "error",
1021
+ );
1022
+ return;
1023
+ }
1024
+ let persistResult: PersistResult = "skipped";
1025
+ if (configWritesEnabled(s)) {
1026
+ s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1027
+ persistResult = saveSettings(s) ? "saved" : "failed";
1028
+ }
1029
+ active?.dispose();
1030
+ active = undefined;
1031
+ apply(pi, ctx, "none");
1032
+ ctx.ui.notify(withPersistenceNote(msg, s, persistResult), "info");
1033
+ };
1034
+
1035
+ pi.on("session_before_switch", (event, _ctx) => {
1036
+ if (event.reason === "resume") {
1037
+ isResuming = true;
1038
+ }
1039
+ });
1040
+
1041
+ pi.on("session_start", (event, ctx) => {
1042
+ const s = readSettings(settingsPath);
1043
+ if (!s) {
1044
+ ctx.ui.notify(
1045
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created and a fresh default restored.",
1046
+ "error",
1047
+ );
1048
+ return;
1049
+ }
1050
+ const h = getCCHeaderConfig(s);
1051
+ state = stateFromConfig(h);
1052
+ if (state.disabled) return;
1053
+
1054
+ if (state.quoteMode && state.sloganOn) {
1055
+ const quote = getRandomQuote(ctx);
1056
+ if (quote) {
1057
+ state.slogan = quote;
1058
+ if (configWritesEnabled(s)) {
1059
+ s.ccHeader = { ...getCCHeaderConfig(s), ...stateToConfig() };
1060
+ saveSettings(s);
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ configStartupEnabled(s);
1066
+ invalidateStats();
1067
+ framesDirty = true;
1068
+ recomputeFrames();
1069
+ const skipAnimation =
1070
+ event.reason === "reload" ||
1071
+ isResuming ||
1072
+ (event.reason === "startup" &&
1073
+ (process.argv.includes("-r") ||
1074
+ process.argv.includes("--resume") ||
1075
+ process.argv.includes("--session")));
1076
+ if (isResuming) isResuming = false;
1077
+ setTimeout(() => apply(pi, ctx, "none", skipAnimation), 0);
1078
+ });
1079
+
1080
+ /* ── /pch command ── */
1081
+ pi.registerCommand("pch", {
1082
+ description:
1083
+ "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)",
1084
+ handler: async (args, ctx) => {
1085
+ const s = readSettings(settingsPath);
1086
+ if (!s) {
1087
+ ctx.ui.notify(
1088
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1089
+ "error",
1090
+ );
1091
+ return;
1092
+ }
1093
+
1094
+ // Parse flags
1095
+ const argv = args?.trim().split(/\s+/) ?? [];
1096
+ if (argv.length === 0 || argv[0] === "--h" || argv[0] === "-h") {
1097
+ ctx.ui.notify(
1098
+ `pch flags:
1099
+ --tg Toggle header enable/disable (next session)
1100
+ --c <code> Logo color (c/a/r/o/y/g/w/b/p) | no arg = show current
1101
+ --i Toggle IBM stripes
1102
+ --m Toggle Minecraft gradient
1103
+ --sp [ms] Animation speed (25/50/75/100) | no arg = show current
1104
+ --v [all|pi|off] Version label color | no arg = cycle
1105
+ --ps Toggle pkg skills visibility
1106
+ --s [txt|-c [code]|-d|-quote] Slogan: set text / toggle on-off / -c slogan color (codes like --c) / -d delete / -quote random quote
1107
+ --logo [l1|l2|-d] Custom ASCII logo lines (pipe-separated) | -d = restore built-in
1108
+ --df Reset to developer defaults
1109
+ --cl Clear all config (for uninstall)
1110
+ --ml Toggle model/thinking line
1111
+ --h Show this help`,
1112
+ "info",
1113
+ );
1114
+ return;
1115
+ }
1116
+
1117
+ // Find first flag
1118
+ const flag = argv[0];
1119
+ const flagArg = argv[1];
1120
+
1121
+ const doUpdate = (
1122
+ updater: (st: CCHeaderState) => string | null,
1123
+ skipFrames = false,
1124
+ ) => {
1125
+ updateState(
1126
+ ctx,
1127
+ (msg) => reapply(pi, ctx, readSettings(settingsPath), msg),
1128
+ updater,
1129
+ skipFrames,
1130
+ );
1131
+ };
1132
+
1133
+ switch (flag) {
1134
+ case "--tg": {
1135
+ const h = getCCHeaderConfig(s);
1136
+ if (state.disabled) {
1137
+ state.disabled = false;
1138
+ h.disabled = false;
1139
+ s.ccHeader = h;
1140
+ invalidateStats();
1141
+ configStartupEnabled(s);
1142
+ reapply(
1143
+ pi,
1144
+ ctx,
1145
+ s,
1146
+ configWritesEnabled(s)
1147
+ ? "pi-cc-header: ENABLED"
1148
+ : "pi-cc-header: ENABLED for this session only",
1149
+ );
1150
+ } else {
1151
+ state.disabled = true;
1152
+ h.disabled = true;
1153
+ active?.dispose();
1154
+ active = undefined;
1155
+ ctx.ui.setHeader(undefined);
1156
+ if (configWritesEnabled(s)) {
1157
+ s.ccHeader = h;
1158
+ s.quietStartup = false;
1159
+ s.clearOnStart = false;
1160
+ const saved = saveSettings(s);
1161
+ const persistResult: PersistResult = saved ? "saved" : "failed";
1162
+ ctx.ui.notify(
1163
+ withPersistenceNote(
1164
+ "pi-cc-header: DISABLED. Takes effect next session. /pch --tg to re-enable.",
1165
+ s,
1166
+ persistResult,
1167
+ ),
1168
+ "info",
1169
+ );
1170
+ } else {
1171
+ ctx.ui.notify(
1172
+ "pi-cc-header: DISABLED for this session only. Config writes are disabled; /pch --tg to re-enable.",
1173
+ "info",
1174
+ );
1175
+ }
1176
+ }
1177
+ return;
1178
+ }
1179
+
1180
+ case "--c": {
1181
+ if (!flagArg) {
1182
+ ctx.ui.notify(
1183
+ `Header color: ${state.logoColorKey} (${COLOR_NAMES[state.logoColorKey]}). Available: ${Object.entries(COLOR_NAMES)
1184
+ .map(([k, n]) => `${k}=${n}`)
1185
+ .join(" ")}`,
1186
+ "info",
1187
+ );
1188
+ return;
1189
+ }
1190
+ doUpdate((st) => {
1191
+ if (!CMAP[flagArg]) {
1192
+ ctx.ui.notify(
1193
+ `Invalid color: "${flagArg}". Available: ${Object.keys(CMAP).join(" ")}`,
1194
+ "error",
1195
+ );
1196
+ return null;
1197
+ }
1198
+ st.logoColorKey = flagArg;
1199
+ return `Color: ${flagArg}`;
1200
+ });
1201
+ return;
1202
+ }
1203
+
1204
+ case "--i": {
1205
+ doUpdate((st) => {
1206
+ st.stripeEnabled = !st.stripeEnabled;
1207
+ return `IBM-style: ${st.stripeEnabled ? "ON" : "OFF"}`;
1208
+ });
1209
+ return;
1210
+ }
1211
+
1212
+ case "--m": {
1213
+ doUpdate((st) => {
1214
+ st.gradientOn = !st.gradientOn;
1215
+ return `Minecraft-style: ${st.gradientOn ? "ON" : "OFF"}`;
1216
+ });
1217
+ return;
1218
+ }
1219
+
1220
+ case "--sp": {
1221
+ if (!flagArg) {
1222
+ ctx.ui.notify(
1223
+ `Animation speed: ${state.logoInterval}ms. Available: ${SPEEDS.join(" ")}`,
1224
+ "info",
1225
+ );
1226
+ return;
1227
+ }
1228
+ const n = Number(flagArg);
1229
+ if (!(SPEEDS as readonly number[]).includes(n)) {
1230
+ ctx.ui.notify(
1231
+ `Invalid speed: "${n}". Available: ${SPEEDS.join(" ")}`,
1232
+ "error",
1233
+ );
1234
+ return;
1235
+ }
1236
+ doUpdate((st) => {
1237
+ st.logoInterval = n as (typeof SPEEDS)[number];
1238
+ return `Animation speed: ${st.logoInterval}ms`;
1239
+ });
1240
+ return;
1241
+ }
1242
+
1243
+ case "--v": {
1244
+ if (flagArg) {
1245
+ const v = flagArg.trim();
1246
+ if (!["all", "pi", "off"].includes(v)) {
1247
+ ctx.ui.notify(
1248
+ `Invalid value: "${v}". Available: all, pi, off.`,
1249
+ "error",
1250
+ );
1251
+ return;
1252
+ }
1253
+ doUpdate(
1254
+ (st) => {
1255
+ st.versionColored = v === "all" ? 2 : v === "pi" ? 1 : 0;
1256
+ return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1257
+ },
1258
+ true,
1259
+ );
1260
+ return;
1261
+ }
1262
+ doUpdate(
1263
+ (st) => {
1264
+ st.versionColored = (st.versionColored + 1) % 3;
1265
+ return `Version label color: ${["OFF", "Pi only", "Pi+ver"][st.versionColored]}`;
1266
+ },
1267
+ true,
1268
+ );
1269
+ return;
1270
+ }
1271
+
1272
+ case "--ps": {
1273
+ doUpdate((st) => {
1274
+ st.showPkgSkills = !st.showPkgSkills;
1275
+ return `Pkg skills: ${st.showPkgSkills ? "VISIBLE" : "HIDDEN"}`;
1276
+ });
1277
+ return;
1278
+ }
1279
+
1280
+ case "--s": {
1281
+ doUpdate((st) => {
1282
+ if (!flagArg) {
1283
+ if (!st.slogan) {
1284
+ ctx.ui.notify(
1285
+ "Command unavailable: no slogan set. Use /pch --s <text> to set one.",
1286
+ "error",
1287
+ );
1288
+ return null;
1289
+ }
1290
+ st.sloganOn = !st.sloganOn;
1291
+ return st.sloganOn ? "Slogan: ON" : "Slogan: OFF";
1292
+ }
1293
+ if (flagArg === "-c") {
1294
+ if (argv[2]) {
1295
+ const c = argv[2].trim();
1296
+ if (!CMAP[c]) {
1297
+ ctx.ui.notify(`Invalid slogan color: "${c}". Available: c a r o y g w b p`, "error");
1298
+ return null;
1299
+ }
1300
+ st.sloganColorKey = c;
1301
+ st.sloganColor = true;
1302
+ return `Slogan color: ${c} (${COLOR_NAMES[c]})`;
1303
+ }
1304
+ st.sloganColor = !st.sloganColor;
1305
+ return st.sloganColor ? `Slogan color: ${state.sloganColorKey} (${COLOR_NAMES[state.sloganColorKey]})` : "Slogan color: OFF";
1306
+ }
1307
+ if (flagArg === "-d") {
1308
+ st.slogan = "";
1309
+ st.sloganOn = false;
1310
+ st.quoteMode = false;
1311
+ return "Slogan: deleted";
1312
+ }
1313
+ if (flagArg === "-quote") {
1314
+ const quote = getRandomQuote(ctx);
1315
+ if (!quote) {
1316
+ ctx.ui.notify(
1317
+ "No quotes found. Create quotes.json in project root or package dir.",
1318
+ "error",
1319
+ );
1320
+ return null;
1321
+ }
1322
+ st.slogan = quote;
1323
+ st.sloganOn = true;
1324
+ st.quoteMode = true;
1325
+ return `Slogan (quote): ${quote.replace(/\n/g, " ")}`;
1326
+ }
1327
+ const text = flagArg.trim();
1328
+ if (!text) {
1329
+ ctx.ui.notify(
1330
+ `Invalid slogan: "". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1331
+ "error",
1332
+ );
1333
+ return null;
1334
+ }
1335
+ if (text.length > MAX_SLOGAN_LENGTH) {
1336
+ ctx.ui.notify(
1337
+ `Invalid slogan: "${text}". Slogan must be between 1 and ${MAX_SLOGAN_LENGTH} characters.`,
1338
+ "error",
1339
+ );
1340
+ return null;
1341
+ }
1342
+ st.slogan = text;
1343
+ st.sloganOn = true;
1344
+ st.quoteMode = false;
1345
+ return `Slogan: ${text}`;
1346
+ });
1347
+ return;
1348
+ }
1349
+
1350
+ case "--df": {
1351
+ state = { ...DEFAULT_STATE };
1352
+ framesDirty = true;
1353
+ recomputeFrames();
1354
+ invalidateStats();
1355
+ const s2 = readSettings(settingsPath);
1356
+ if (!s2) {
1357
+ ctx.ui.notify(
1358
+ "pi-cc-header: settings.json is corrupted or unreadable. A backup has been created.",
1359
+ "error",
1360
+ );
1361
+ return;
1362
+ }
1363
+ reapply(pi, ctx, s2, "Reset to developer defaults");
1364
+ return;
1365
+ }
1366
+
1367
+ case "--cl": {
1368
+ if (!configWritesEnabled(s)) {
1369
+ ctx.ui.notify(
1370
+ "pi-cc-header: /pch --cl is unavailable when ccHeader.readOnlyConfig=true. Remove the config declaratively, then uninstall the package.",
1371
+ "info",
1372
+ );
1373
+ return;
1374
+ }
1375
+ delete s.ccHeader;
1376
+ delete s.quietStartup;
1377
+ delete s.clearOnStart;
1378
+ const persisted = saveSettings(s);
1379
+ state = { ...DEFAULT_STATE, disabled: true };
1380
+ active?.dispose();
1381
+ active = undefined;
1382
+ ctx.ui.setHeader(undefined);
1383
+ ctx.ui.notify(
1384
+ persisted
1385
+ ? "pi-cc-header Config: cleared. You can now uninstall the package."
1386
+ : "pi-cc-header Config: cleared for this session only. Could not save settings.json.",
1387
+ "info",
1388
+ );
1389
+ return;
1390
+ }
1391
+
1392
+ case "--ml": {
1393
+ doUpdate((st) => {
1394
+ st.showModelLine = !st.showModelLine;
1395
+ return `Model/thinking line: ${st.showModelLine ? "ON" : "OFF"}`;
1396
+ }, true);
1397
+ return;
1398
+ }
1399
+
1400
+ case "--logo": {
1401
+ // /pch --logo -d → restore built-in Pi logo
1402
+ // /pch --logo line1 | line2 → set custom ASCII art (pipe-separated lines)
1403
+ if (flagArg === "-d") {
1404
+ doUpdate((st) => { st.customLogoLines = null; return "Logo: restored to built-in"; });
1405
+ return;
1406
+ }
1407
+ const raw = argv.slice(1).join(" ");
1408
+ if (!raw.trim()) {
1409
+ ctx.ui.notify(
1410
+ state.customLogoLines
1411
+ ? `Custom logo: ${state.customLogoLines.length} lines set. /pch --logo -d to restore built-in.`
1412
+ : "No custom logo set. Use /pch --logo line1 | line2 | ... to set one.",
1413
+ "info",
1414
+ );
1415
+ return;
1416
+ }
1417
+ const lines = raw.split("|").map((l) => l.trim());
1418
+ doUpdate((st) => { st.customLogoLines = lines; return `Logo: ${lines.length}-line custom ASCII set`; });
1419
+ return;
1420
+ }
1421
+
1422
+ default: {
1423
+ ctx.ui.notify(
1424
+ `Unknown flag: ${flag}. Use /pch --h for help.`,
1425
+ "error",
1426
+ );
1427
+ return;
1428
+ }
1429
+ }
1430
+ },
1431
+ });
1432
+ }
1433
+