@owlburtoe/pi-cc-tools 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,818 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Loader } from "@earendil-works/pi-tui";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Patch built-in Loader with Claude/OpenBrawd-style glyphs.
8
+ // Keep animation cadence constant so the spinner doesn't appear to slow down
9
+ // or freeze as the session grows.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const RAW_ANSI_RE = /\x1b\[[0-9;]*m/;
13
+ const RESET = "\x1b[0m";
14
+
15
+ // Defaults match the previous hardcoded values so behavior is identical
16
+ // when no theme is available or themeAdaptive=false. `applyThemeColors`
17
+ // below re-derives them from the active pi theme each tick.
18
+ let CLAUDE_ORANGE = "\x1b[38;2;215;119;87m";
19
+ let STATUS_DIM = "\x1b[38;2;153;153;153m";
20
+
21
+ // Short TTL so /cc-spinner changes are picked up within ~1s without
22
+ // re-reading the file on every 250ms spinner tick.
23
+ type SpinnerVerbMode = "append" | "replace";
24
+ interface SpinnerSettings {
25
+ adaptive: boolean;
26
+ verbColor: string;
27
+ statusColor: string;
28
+ verbs: readonly string[];
29
+ }
30
+
31
+ let _spinnerSettingsCache: { value: SpinnerSettings; expires: number } | null = null;
32
+ const SPINNER_SETTINGS_TTL_MS = 1_000;
33
+ const MAX_CUSTOM_SPINNER_VERBS = 200;
34
+ const MAX_SPINNER_VERB_LENGTH = 48;
35
+ const ANSI_ESCAPE_SEQUENCE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
36
+ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g;
37
+ // Cross-extension bust signal: /cc-spinner in index.ts bumps this counter
38
+ // and we drop the cache when it changes.
39
+ const SPINNER_BUST_KEY = Symbol.for("pi-claude-style-tools:spinner-settings-bust");
40
+ let _spinnerLastBust = 0;
41
+
42
+ function sanitizeSpinnerVerb(value: unknown): string | null {
43
+ if (typeof value !== "string") return null;
44
+ const cleaned = value
45
+ .replace(ANSI_ESCAPE_SEQUENCE_RE, "")
46
+ .replace(CONTROL_CHARS_RE, "")
47
+ .trim();
48
+ if (!cleaned) return null;
49
+ return Array.from(cleaned).slice(0, MAX_SPINNER_VERB_LENGTH).join("");
50
+ }
51
+
52
+ function sanitizeSpinnerVerbs(value: unknown): string[] {
53
+ if (!Array.isArray(value)) return [];
54
+ const seen = new Set<string>();
55
+ const verbs: string[] = [];
56
+ for (const item of value) {
57
+ const verb = sanitizeSpinnerVerb(item);
58
+ if (!verb) continue;
59
+ const key = verb.toLocaleLowerCase();
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ verbs.push(verb);
63
+ if (verbs.length >= MAX_CUSTOM_SPINNER_VERBS) break;
64
+ }
65
+ return verbs;
66
+ }
67
+
68
+ function resolveSpinnerVerbs(customVerbs: readonly string[] | null, mode: SpinnerVerbMode): readonly string[] {
69
+ if (!customVerbs || customVerbs.length === 0) return DEFAULT_SPINNER_VERBS;
70
+ if (mode === "replace") return customVerbs;
71
+ const seen = new Set<string>();
72
+ const merged: string[] = [];
73
+ for (const verb of [...DEFAULT_SPINNER_VERBS, ...customVerbs]) {
74
+ const key = verb.toLocaleLowerCase();
75
+ if (seen.has(key)) continue;
76
+ seen.add(key);
77
+ merged.push(verb);
78
+ }
79
+ return merged.length > 0 ? merged : DEFAULT_SPINNER_VERBS;
80
+ }
81
+
82
+ function readSpinnerSettings(): SpinnerSettings {
83
+ const now = Date.now();
84
+ const bust = ((globalThis as any)[SPINNER_BUST_KEY] as number | undefined) ?? 0;
85
+ if (bust !== _spinnerLastBust) {
86
+ _spinnerLastBust = bust;
87
+ _spinnerSettingsCache = null;
88
+ }
89
+ if (_spinnerSettingsCache && _spinnerSettingsCache.expires > now) {
90
+ return _spinnerSettingsCache.value;
91
+ }
92
+ let adaptive = true;
93
+ // Spinner glyph and verb share one theme color so they read as a single
94
+ // working indicator. `spinnerVerbColor` is still accepted for older config.
95
+ let verbColor = "borderAccent";
96
+ let statusColor = "muted";
97
+ let customVerbs: string[] | null = null;
98
+ let verbMode: SpinnerVerbMode = "append";
99
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
100
+ for (const p of paths) {
101
+ try {
102
+ if (!p || !existsSync(p)) continue;
103
+ const raw = JSON.parse(readFileSync(p, "utf8"));
104
+ if (raw && typeof raw === "object") {
105
+ if (raw.themeAdaptive === false) adaptive = false;
106
+ if (typeof raw.spinnerVerbColor === "string" && raw.spinnerVerbColor.length > 0) verbColor = raw.spinnerVerbColor;
107
+ if (typeof raw.spinnerColor === "string" && raw.spinnerColor.length > 0) verbColor = raw.spinnerColor;
108
+ if (typeof raw.spinnerStatusColor === "string" && raw.spinnerStatusColor.length > 0) statusColor = raw.spinnerStatusColor;
109
+ if (raw.spinnerVerbMode === "append" || raw.spinnerVerbMode === "replace") verbMode = raw.spinnerVerbMode;
110
+ if (Array.isArray(raw.spinnerVerbs)) customVerbs = sanitizeSpinnerVerbs(raw.spinnerVerbs);
111
+ }
112
+ } catch { /* ignore */ }
113
+ }
114
+ const value: SpinnerSettings = { adaptive, verbColor, statusColor, verbs: resolveSpinnerVerbs(customVerbs, verbMode) };
115
+ _spinnerSettingsCache = { value, expires: now + SPINNER_SETTINGS_TTL_MS };
116
+ return value;
117
+ }
118
+
119
+ // Original Claude-style values restored when the user turns adaptive off.
120
+ const _DEFAULT_CLAUDE_ORANGE = "\x1b[38;2;215;119;87m";
121
+ const _DEFAULT_STATUS_DIM = "\x1b[38;2;153;153;153m";
122
+
123
+ let _themeColorsCacheTheme: unknown = null;
124
+ let _themeColorsLastAdaptive: boolean | null = null;
125
+ let _themeColorsLastVerbKey: string | null = null;
126
+ let _themeColorsLastStatusKey: string | null = null;
127
+
128
+ function resolveThemeColor(theme: any, key: string, fallbackKey: string): string | null {
129
+ if (!theme || typeof theme.getFgAnsi !== "function") return null;
130
+ try {
131
+ const v = theme.getFgAnsi(key);
132
+ if (typeof v === "string" && v.length > 0) return v;
133
+ } catch { /* ignore */ }
134
+ if (fallbackKey !== key) {
135
+ try {
136
+ const v = theme.getFgAnsi(fallbackKey);
137
+ if (typeof v === "string" && v.length > 0) return v;
138
+ } catch { /* ignore */ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ function applyThemeColors(theme: any): void {
144
+ const { adaptive, verbColor, statusColor } = readSpinnerSettings();
145
+
146
+ // Respond to runtime toggles (themeAdaptive or spinner color key changes)
147
+ // without restarting pi.
148
+ const settingsChanged = _themeColorsLastAdaptive !== adaptive
149
+ || _themeColorsLastVerbKey !== verbColor
150
+ || _themeColorsLastStatusKey !== statusColor;
151
+ if (settingsChanged) {
152
+ _themeColorsLastAdaptive = adaptive;
153
+ _themeColorsLastVerbKey = verbColor;
154
+ _themeColorsLastStatusKey = statusColor;
155
+ _themeColorsCacheTheme = null;
156
+ if (!adaptive) {
157
+ CLAUDE_ORANGE = _DEFAULT_CLAUDE_ORANGE;
158
+ STATUS_DIM = _DEFAULT_STATUS_DIM;
159
+ }
160
+ }
161
+
162
+ if (!theme || !adaptive) return;
163
+ if (_themeColorsCacheTheme === theme) return;
164
+ _themeColorsCacheTheme = theme;
165
+
166
+ const verb = resolveThemeColor(theme, verbColor, "accent");
167
+ if (verb) CLAUDE_ORANGE = verb;
168
+ const status = resolveThemeColor(theme, statusColor, "muted");
169
+ if (status) STATUS_DIM = status;
170
+ }
171
+
172
+ // Match OpenBrawd's spinner glyph set, with the final Ghostty frame restored
173
+ // to ✽ because the user's font-codepoint-map now centers it correctly.
174
+ function getDefaultSpinnerCharacters(): string[] {
175
+ if (process.env.TERM === "xterm-ghostty") {
176
+ return ["·", "✢", "✳", "✶", "✻", "✽"];
177
+ }
178
+ return process.platform === "darwin"
179
+ ? ["·", "✢", "✳", "✶", "✻", "✽"]
180
+ : ["·", "✢", "*", "✶", "✻", "✽"];
181
+ }
182
+
183
+ const SPINNER_CHARS = getDefaultSpinnerCharacters();
184
+ const OB_FRAMES = [...SPINNER_CHARS, ...[...SPINNER_CHARS].reverse()];
185
+ const LOADER_INTERVAL_MS = 250;
186
+ const LOADER_LAST_TEXT = Symbol.for("pi-claude-style-tools:loader-last-text");
187
+ const LOADER_ACTIVE = Symbol.for("pi-claude-style-tools:loader-active");
188
+ const LOADER_GENERATION = Symbol.for("pi-claude-style-tools:loader-generation");
189
+ const ACTIVE_UI_SYMBOL = Symbol.for("pi-claude-style-tools:active-ui");
190
+
191
+ function getLoaderIntervalMs(_loader: any): number {
192
+ return LOADER_INTERVAL_MS;
193
+ }
194
+
195
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | null | undefined): void {
196
+ (timer as any)?.unref?.();
197
+ }
198
+
199
+ (Loader.prototype as any).updateDisplay = function patchedUpdateDisplay() {
200
+ applyThemeColors(this.ui?.theme);
201
+ const frame = OB_FRAMES[this.currentFrame % OB_FRAMES.length];
202
+ const message = typeof this.message === "string" && RAW_ANSI_RE.test(this.message)
203
+ ? this.message
204
+ : this.messageColorFn(this.message);
205
+ const nextText = `${CLAUDE_ORANGE}${frame}${RESET} ${message}`;
206
+ if ((this as any)[LOADER_LAST_TEXT] === nextText) return;
207
+ (this as any)[LOADER_LAST_TEXT] = nextText;
208
+ this.setText(nextText);
209
+ if (this.ui && !(this.ui as any).stopped) {
210
+ (globalThis as any)[ACTIVE_UI_SYMBOL] = this.ui;
211
+ this.ui.requestRender();
212
+ }
213
+ };
214
+
215
+ Loader.prototype.start = function patchedStart() {
216
+ this.stop();
217
+ (this as any)[LOADER_ACTIVE] = true;
218
+ const generation = ((this as any)[LOADER_GENERATION] ?? 0) + 1;
219
+ (this as any)[LOADER_GENERATION] = generation;
220
+ delete (this as any)[LOADER_LAST_TEXT];
221
+ (this as any).updateDisplay();
222
+ const scheduleNext = () => {
223
+ if ((this as any)[LOADER_ACTIVE] !== true || (this as any)[LOADER_GENERATION] !== generation) return;
224
+ const intervalMs = getLoaderIntervalMs(this);
225
+ const timer = setTimeout(() => {
226
+ (this as any).intervalId = null;
227
+ if ((this as any)[LOADER_ACTIVE] !== true || (this as any)[LOADER_GENERATION] !== generation) return;
228
+ (this as any).currentFrame = ((this as any).currentFrame + 1) % OB_FRAMES.length;
229
+ (this as any).updateDisplay();
230
+ scheduleNext();
231
+ }, intervalMs);
232
+ unrefTimer(timer);
233
+ (this as any).intervalId = timer;
234
+ };
235
+ scheduleNext();
236
+ };
237
+
238
+ Loader.prototype.stop = function patchedStop() {
239
+ (this as any)[LOADER_ACTIVE] = false;
240
+ (this as any)[LOADER_GENERATION] = ((this as any)[LOADER_GENERATION] ?? 0) + 1;
241
+ if ((this as any).intervalId) {
242
+ clearTimeout((this as any).intervalId);
243
+ (this as any).intervalId = null;
244
+ }
245
+ };
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Spinner verbs — fun/whimsical loading messages (different set from OpenBrawd)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ const DEFAULT_SPINNER_VERBS = [
252
+ "Accomplishing",
253
+ "Actioning",
254
+ "Actualizing",
255
+ "Architecting",
256
+ "Baking",
257
+ "Beaming",
258
+ "Beboppin'",
259
+ "Befuddling",
260
+ "Billowing",
261
+ "Blanching",
262
+ "Bloviating",
263
+ "Boogieing",
264
+ "Boondoggling",
265
+ "Booping",
266
+ "Bootstrapping",
267
+ "Brewing",
268
+ "Bunning",
269
+ "Burrowing",
270
+ "Calculating",
271
+ "Canoodling",
272
+ "Caramelizing",
273
+ "Cascading",
274
+ "Catapulting",
275
+ "Cerebrating",
276
+ "Channeling",
277
+ "Choreographing",
278
+ "Churning",
279
+ "Coalescing",
280
+ "Cogitating",
281
+ "Combobulating",
282
+ "Composing",
283
+ "Computing",
284
+ "Concocting",
285
+ "Considering",
286
+ "Contemplating",
287
+ "Cooking",
288
+ "Crafting",
289
+ "Creating",
290
+ "Crunching",
291
+ "Crystallizing",
292
+ "Cultivating",
293
+ "Deciphering",
294
+ "Deliberating",
295
+ "Determining",
296
+ "Dilly-dallying",
297
+ "Discombobulating",
298
+ "Doodling",
299
+ "Drizzling",
300
+ "Ebbing",
301
+ "Effecting",
302
+ "Elucidating",
303
+ "Embellishing",
304
+ "Enchanting",
305
+ "Envisioning",
306
+ "Evaporating",
307
+ "Fermenting",
308
+ "Fiddle-faddling",
309
+ "Finagling",
310
+ "Flambéing",
311
+ "Flibbertigibbeting",
312
+ "Flowing",
313
+ "Flummoxing",
314
+ "Fluttering",
315
+ "Forging",
316
+ "Forming",
317
+ "Frolicking",
318
+ "Frosting",
319
+ "Gallivanting",
320
+ "Galloping",
321
+ "Garnishing",
322
+ "Generating",
323
+ "Gesticulating",
324
+ "Germinating",
325
+ "Grooving",
326
+ "Gusting",
327
+ "Harmonizing",
328
+ "Hashing",
329
+ "Hatching",
330
+ "Herding",
331
+ "Hullaballooing",
332
+ "Hyperspacing",
333
+ "Ideating",
334
+ "Imagining",
335
+ "Improvising",
336
+ "Incubating",
337
+ "Inferring",
338
+ "Infusing",
339
+ "Ionizing",
340
+ "Jitterbugging",
341
+ "Julienning",
342
+ "Kneading",
343
+ "Leavening",
344
+ "Levitating",
345
+ "Lollygagging",
346
+ "Manifesting",
347
+ "Marinating",
348
+ "Meandering",
349
+ "Metamorphosing",
350
+ "Misting",
351
+ "Moonwalking",
352
+ "Moseying",
353
+ "Mulling",
354
+ "Mustering",
355
+ "Musing",
356
+ "Nebulizing",
357
+ "Nesting",
358
+ "Noodling",
359
+ "Nucleating",
360
+ "Orbiting",
361
+ "Orchestrating",
362
+ "Osmosing",
363
+ "Perambulating",
364
+ "Percolating",
365
+ "Perusing",
366
+ "Philosophising",
367
+ "Photosynthesizing",
368
+ "Pollinating",
369
+ "Pondering",
370
+ "Pontificating",
371
+ "Pouncing",
372
+ "Precipitating",
373
+ "Prestidigitating",
374
+ "Processing",
375
+ "Proofing",
376
+ "Propagating",
377
+ "Puttering",
378
+ "Puzzling",
379
+ "Quantumizing",
380
+ "Razzle-dazzling",
381
+ "Razzmatazzing",
382
+ "Recombobulating",
383
+ "Reticulating",
384
+ "Roosting",
385
+ "Ruminating",
386
+ "Sautéing",
387
+ "Scampering",
388
+ "Schlepping",
389
+ "Scurrying",
390
+ "Seasoning",
391
+ "Shenaniganing",
392
+ "Shimmying",
393
+ "Simmering",
394
+ "Skedaddling",
395
+ "Sketching",
396
+ "Slithering",
397
+ "Smooshing",
398
+ "Sock-hopping",
399
+ "Spelunking",
400
+ "Spinning",
401
+ "Sprouting",
402
+ "Stewing",
403
+ "Sublimating",
404
+ "Swirling",
405
+ "Swooping",
406
+ "Symbioting",
407
+ "Synthesizing",
408
+ "Tempering",
409
+ "Thinking",
410
+ "Thundering",
411
+ "Tinkering",
412
+ "Tomfoolering",
413
+ "Topsy-turvying",
414
+ "Transfiguring",
415
+ "Transmuting",
416
+ "Twisting",
417
+ "Undulating",
418
+ "Unfurling",
419
+ "Unravelling",
420
+ "Vibing",
421
+ "Waddling",
422
+ "Wandering",
423
+ "Warping",
424
+ "Whatchamacalliting",
425
+ "Whirlpooling",
426
+ "Whirring",
427
+ "Whisking",
428
+ "Wibbling",
429
+ "Working",
430
+ "Wrangling",
431
+ "Zesting",
432
+ "Zigzagging",
433
+ ];
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Spinner glyph characters are now patched into the Loader above.
437
+ // No separate glyph prefix needed.
438
+ // ---------------------------------------------------------------------------
439
+
440
+ function pickVerb(): string {
441
+ const verbs = readSpinnerSettings().verbs;
442
+ return verbs[Math.floor(Math.random() * verbs.length)] ?? DEFAULT_SPINNER_VERBS[0];
443
+ }
444
+
445
+ /** Format elapsed ms as compact duration: 5s, 1m 23s, 1h 2m 3s */
446
+ function formatDuration(ms: number): string {
447
+ const totalSec = Math.floor(ms / 1000);
448
+ const h = Math.floor(totalSec / 3600);
449
+ const m = Math.floor((totalSec % 3600) / 60);
450
+ const s = totalSec % 60;
451
+ if (h > 0) return `${h}h ${m}m ${s}s`;
452
+ if (m > 0) return `${m}m ${s}s`;
453
+ return `${s}s`;
454
+ }
455
+
456
+ function formatCount(value: number): string {
457
+ return new Intl.NumberFormat("en-US").format(value);
458
+ }
459
+
460
+ function estimateResponseLength(message: any): number {
461
+ if (!Array.isArray(message?.content)) return 0;
462
+ return message.content.reduce((sum: number, block: any) =>
463
+ sum + (block?.type === "text" && typeof block.text === "string" ? block.text.length : 0), 0);
464
+ }
465
+
466
+ function textBlockLengths(message: any): number[] {
467
+ if (!Array.isArray(message?.content)) return [];
468
+ const lengths: number[] = [];
469
+ for (let i = 0; i < message.content.length; i++) {
470
+ const block = message.content[i];
471
+ if (block?.type === "text" && typeof block.text === "string") {
472
+ lengths[i] = block.text.length;
473
+ }
474
+ }
475
+ return lengths;
476
+ }
477
+
478
+ function statusText(text: string): string {
479
+ return `${STATUS_DIM}${text}${RESET}`;
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Extension
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /** Threshold before showing elapsed time in status parentheses */
487
+ const SHOW_TIMER_AFTER_MS = 30_000;
488
+
489
+ /** How long to preserve "thought for Ns" across turns */
490
+ const THOUGHT_DISPLAY_MS = 3_500;
491
+
492
+ /** Minimum thinking duration before showing "thought for Ns" */
493
+ const MIN_THINKING_SHOW_MS = 100;
494
+
495
+ /** Message refresh cadence. Keep constant so status updates don't stall on long sessions. */
496
+ const WORKING_MESSAGE_INTERVAL_MS = 1_000;
497
+
498
+ /** Completion message linger */
499
+ const TURN_COMPLETION_MS = 2_500;
500
+
501
+
502
+ export default function (pi: ExtensionAPI) {
503
+ let agentStartTime = 0;
504
+ let turnStartTime = 0;
505
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
506
+ let completionTimer: ReturnType<typeof setTimeout> | null = null;
507
+ let thoughtStatusTimer: ReturnType<typeof setTimeout> | null = null;
508
+ let currentVerb = "";
509
+ let responseLength = 0;
510
+ let responseTextBlockLengths: number[] = [];
511
+ let thinkingStatus: "thinking" | number /* duration ms */ | null = null;
512
+ let thinkingStartTime = 0;
513
+ let thoughtForSetAt = 0;
514
+ let activeTurnId = 0;
515
+ let turnActive = false;
516
+ let lastWorkingMessage: string | null = null;
517
+ let activeCtx: { ui: any; hasUI: boolean } | null = null;
518
+
519
+ function getEffortSuffix(): string {
520
+ try {
521
+ const level = pi.getThinkingLevel();
522
+ if (!level || level === "off") return "";
523
+ return ` with ${level} effort`;
524
+ } catch {
525
+ return "";
526
+ }
527
+ }
528
+
529
+ function buildWorkingMessage(): string {
530
+ const elapsed = Date.now() - (agentStartTime || turnStartTime);
531
+ const tokenCount = Math.max(0, Math.round(responseLength / 4));
532
+ const statusParts: string[] = [];
533
+
534
+ if (thinkingStatus === "thinking") {
535
+ statusParts.push(`thinking${getEffortSuffix()}`);
536
+ } else if (typeof thinkingStatus === "number") {
537
+ statusParts.push(`thought for ${Math.max(1, Math.round(thinkingStatus / 1000))}s`);
538
+ }
539
+
540
+ if (tokenCount > 0) {
541
+ statusParts.push(`↓ ${formatCount(tokenCount)} tokens`);
542
+ }
543
+
544
+ if (elapsed > SHOW_TIMER_AFTER_MS || thinkingStatus !== null || tokenCount > 0) {
545
+ statusParts.push(formatDuration(elapsed));
546
+ }
547
+
548
+ let message = `${CLAUDE_ORANGE}${currentVerb}…${RESET}`;
549
+ if (statusParts.length > 0) {
550
+ message += statusText(` (${statusParts.join(" · ")})`);
551
+ }
552
+ return message;
553
+ }
554
+
555
+ function setResponseTextBlockLength(index: number, length: number): void {
556
+ const previous = responseTextBlockLengths[index] ?? 0;
557
+ responseTextBlockLengths[index] = Math.max(0, length);
558
+ responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
559
+ }
560
+
561
+ function resetResponseTracking(message?: any): void {
562
+ responseTextBlockLengths = message ? textBlockLengths(message) : [];
563
+ responseLength = message ? estimateResponseLength(message) : 0;
564
+ }
565
+
566
+ function syncWorkingMessage(force = false): void {
567
+ if (!activeCtx?.hasUI) return;
568
+ // Re-derive colors on every tick so /cc-spinner color/status changes
569
+ // take effect within ~250 ms without waiting for the next pi event.
570
+ // applyThemeColors is identity-cached on (theme, spinnerKey, statusKey) so
571
+ // this is cheap when nothing changed.
572
+ applyThemeColors(activeCtx.ui?.theme);
573
+ const nextMessage = buildWorkingMessage();
574
+ if (!force && nextMessage === lastWorkingMessage) return;
575
+ lastWorkingMessage = nextMessage;
576
+ try {
577
+ activeCtx.ui.setWorkingMessage(nextMessage);
578
+ } catch { /* noop */ }
579
+ }
580
+
581
+ function restoreDefaultWorkingMessage(): void {
582
+ lastWorkingMessage = null;
583
+ if (!activeCtx?.hasUI) return;
584
+ try {
585
+ activeCtx.ui.setWorkingMessage();
586
+ } catch { /* noop */ }
587
+ }
588
+
589
+ function getWorkingMessageIntervalMs(): number {
590
+ const elapsed = Date.now() - (agentStartTime || turnStartTime);
591
+ const tokenCount = Math.max(0, Math.round(responseLength / 4));
592
+ // Keep ticking once per second even when idle so /cc-spinner changes
593
+ // take effect within ~1s and elapsed-time crossover into the timer-on
594
+ // state still fires close to 30s. syncWorkingMessage short-circuits
595
+ // when the rendered string is unchanged, so the cost is negligible.
596
+ if (thinkingStatus === null && tokenCount === 0 && elapsed <= SHOW_TIMER_AFTER_MS) {
597
+ return Math.max(250, Math.min(WORKING_MESSAGE_INTERVAL_MS, SHOW_TIMER_AFTER_MS - elapsed + 1));
598
+ }
599
+ return Math.max(250, WORKING_MESSAGE_INTERVAL_MS - (elapsed % WORKING_MESSAGE_INTERVAL_MS));
600
+ }
601
+
602
+ function scheduleRefreshTick(): void {
603
+ if (!turnActive || refreshTimer) return;
604
+ const intervalMs = getWorkingMessageIntervalMs();
605
+ refreshTimer = setTimeout(() => {
606
+ refreshTimer = null;
607
+ syncWorkingMessage();
608
+ scheduleRefreshTick();
609
+ }, intervalMs);
610
+ unrefTimer(refreshTimer);
611
+ }
612
+
613
+ function startRefreshLoop(): void {
614
+ stopRefreshLoop();
615
+ syncWorkingMessage(true);
616
+ scheduleRefreshTick();
617
+ }
618
+
619
+ function rescheduleRefreshLoop(): void {
620
+ if (!turnActive) return;
621
+ stopRefreshLoop();
622
+ scheduleRefreshTick();
623
+ }
624
+
625
+ function stopRefreshLoop(): void {
626
+ if (refreshTimer) {
627
+ clearTimeout(refreshTimer);
628
+ refreshTimer = null;
629
+ }
630
+ }
631
+
632
+ function clearCompletionTimer(): void {
633
+ if (completionTimer) {
634
+ clearTimeout(completionTimer);
635
+ completionTimer = null;
636
+ }
637
+ }
638
+
639
+ function clearThoughtStatusTimer(): void {
640
+ if (thoughtStatusTimer) {
641
+ clearTimeout(thoughtStatusTimer);
642
+ thoughtStatusTimer = null;
643
+ }
644
+ }
645
+
646
+ function scheduleThoughtStatusClear(): void {
647
+ clearThoughtStatusTimer();
648
+ if (typeof thinkingStatus !== "number") return;
649
+ const remaining = THOUGHT_DISPLAY_MS - (Date.now() - thoughtForSetAt);
650
+ if (remaining <= 0) {
651
+ thinkingStatus = null;
652
+ if (turnActive) syncWorkingMessage(true);
653
+ else if (!completionTimer) restoreDefaultWorkingMessage();
654
+ return;
655
+ }
656
+ thoughtStatusTimer = setTimeout(() => {
657
+ thoughtStatusTimer = null;
658
+ if (typeof thinkingStatus !== "number") return;
659
+ if (Date.now() - thoughtForSetAt < THOUGHT_DISPLAY_MS) {
660
+ scheduleThoughtStatusClear();
661
+ return;
662
+ }
663
+ thinkingStatus = null;
664
+ if (turnActive) syncWorkingMessage(true);
665
+ else if (!completionTimer) restoreDefaultWorkingMessage();
666
+ }, remaining);
667
+ unrefTimer(thoughtStatusTimer);
668
+ }
669
+
670
+ function clearDisplay(): void {
671
+ stopRefreshLoop();
672
+ clearCompletionTimer();
673
+ clearThoughtStatusTimer();
674
+ agentStartTime = 0;
675
+ turnStartTime = 0;
676
+ thinkingStatus = null;
677
+ thoughtForSetAt = 0;
678
+ resetResponseTracking();
679
+ restoreDefaultWorkingMessage();
680
+ }
681
+
682
+ function onThinkingEnd(): void {
683
+ if (thinkingStatus !== "thinking") return;
684
+ const duration = Date.now() - thinkingStartTime;
685
+ if (duration < MIN_THINKING_SHOW_MS) {
686
+ thinkingStatus = null;
687
+ clearThoughtStatusTimer();
688
+ return;
689
+ }
690
+ thinkingStatus = duration;
691
+ thoughtForSetAt = Date.now();
692
+ scheduleThoughtStatusClear();
693
+ }
694
+
695
+ pi.on("before_agent_start", async () => {
696
+ // Start once per top-level request. Steering/follow-up messages while the
697
+ // agent is active must not reset the timer.
698
+ if (!agentStartTime) agentStartTime = Date.now();
699
+ });
700
+
701
+ pi.on("agent_start", async () => {
702
+ if (!agentStartTime) agentStartTime = Date.now();
703
+ });
704
+
705
+ pi.on("turn_start", async (_event, ctx) => {
706
+ activeTurnId++;
707
+ turnActive = true;
708
+ activeCtx = ctx;
709
+ applyThemeColors(ctx.ui?.theme);
710
+ turnStartTime = Date.now();
711
+ if (!agentStartTime) agentStartTime = turnStartTime;
712
+ currentVerb = pickVerb();
713
+ resetResponseTracking();
714
+ clearCompletionTimer();
715
+ if (typeof thinkingStatus !== "number" || Date.now() - thoughtForSetAt >= THOUGHT_DISPLAY_MS) {
716
+ thinkingStatus = null;
717
+ clearThoughtStatusTimer();
718
+ } else {
719
+ scheduleThoughtStatusClear();
720
+ }
721
+ startRefreshLoop();
722
+ });
723
+
724
+ pi.on("message_update", async (event, ctx) => {
725
+ activeCtx = ctx;
726
+ applyThemeColors(ctx.ui?.theme);
727
+ const evt = event.assistantMessageEvent;
728
+ let statusChanged = false;
729
+ const previousTokenCount = Math.max(0, Math.round(responseLength / 4));
730
+
731
+ if (evt.type === "start") {
732
+ resetResponseTracking();
733
+ } else if (evt.type === "text_start") {
734
+ setResponseTextBlockLength(evt.contentIndex, 0);
735
+ } else if (evt.type === "text_delta") {
736
+ const previous = responseTextBlockLengths[evt.contentIndex] ?? 0;
737
+ setResponseTextBlockLength(evt.contentIndex, previous + (typeof evt.delta === "string" ? evt.delta.length : 0));
738
+ } else if (evt.type === "text_end") {
739
+ setResponseTextBlockLength(evt.contentIndex, typeof evt.content === "string" ? evt.content.length : 0);
740
+ } else if (evt.type === "done") {
741
+ resetResponseTracking(evt.message);
742
+ } else if (evt.type === "error") {
743
+ resetResponseTracking(evt.error);
744
+ }
745
+
746
+ if (evt.type === "thinking_start") {
747
+ clearThoughtStatusTimer();
748
+ thinkingStatus = "thinking";
749
+ thinkingStartTime = Date.now();
750
+ statusChanged = true;
751
+ }
752
+ if (evt.type === "thinking_end") {
753
+ onThinkingEnd();
754
+ statusChanged = true;
755
+ }
756
+
757
+ if (statusChanged) {
758
+ syncWorkingMessage(true);
759
+ rescheduleRefreshLoop();
760
+ return;
761
+ }
762
+
763
+ const nextTokenCount = Math.max(0, Math.round(responseLength / 4));
764
+ if (previousTokenCount === 0 && nextTokenCount > 0) {
765
+ rescheduleRefreshLoop();
766
+ }
767
+ });
768
+
769
+ pi.on("turn_end", async (_event, ctx) => {
770
+ turnActive = false;
771
+ activeCtx = ctx;
772
+ applyThemeColors(ctx.ui?.theme);
773
+ const turnId = activeTurnId;
774
+ const elapsed = Date.now() - (agentStartTime || turnStartTime);
775
+ stopRefreshLoop();
776
+ clearCompletionTimer();
777
+
778
+ if (typeof thinkingStatus === "number" && Date.now() - thoughtForSetAt >= THOUGHT_DISPLAY_MS) {
779
+ thinkingStatus = null;
780
+ clearThoughtStatusTimer();
781
+ }
782
+
783
+ if (activeCtx?.hasUI) {
784
+ const message = `${STATUS_DIM}✻ Worked for ${formatDuration(elapsed)}${RESET}`;
785
+ lastWorkingMessage = message;
786
+ try {
787
+ activeCtx.ui.setWorkingMessage(message);
788
+ } catch { /* noop */ }
789
+ completionTimer = setTimeout(() => {
790
+ completionTimer = null;
791
+ if (activeTurnId !== turnId) return;
792
+ restoreDefaultWorkingMessage();
793
+ }, TURN_COMPLETION_MS);
794
+ unrefTimer(completionTimer);
795
+ } else if (typeof thinkingStatus !== "number") {
796
+ restoreDefaultWorkingMessage();
797
+ }
798
+
799
+ responseLength = 0;
800
+ responseTextBlockLengths = [];
801
+ });
802
+
803
+ pi.on("agent_end", async () => {
804
+ turnActive = false;
805
+ agentStartTime = 0;
806
+ // Preserve the just-finished "Worked for …" line. Pi emits agent_end
807
+ // immediately after the final turn, so clearing here made the completion
808
+ // status disappear before users could see it.
809
+ if (completionTimer) return;
810
+ clearDisplay();
811
+ });
812
+
813
+ pi.on("session_shutdown", async () => {
814
+ turnActive = false;
815
+ clearDisplay();
816
+ activeCtx = null;
817
+ });
818
+ }