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