pi-better-openai 0.1.18 → 0.1.21

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,627 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { calculateImageRows, getCapabilities, getCellDimensions } from "@earendil-works/pi-tui";
3
+ import type { PetPlacement, PetState, ResolvedConfig } from "./config.ts";
4
+ import {
5
+ animationFrameAt,
6
+ type CodexPetPackage,
7
+ CodexPetKittyManager,
8
+ describeCodexPetSelectionIssue,
9
+ type LoadedCodexPet,
10
+ loadCodexPet,
11
+ nextAnimationFrameDelayMs,
12
+ PET_ANIMATION_ROWS,
13
+ renderCodexPetFrame,
14
+ listCodexPets,
15
+ } from "./pets.ts";
16
+ import { truncateToWidth } from "./format.ts";
17
+
18
+ const PET_RESIZE_FREEZE_MS = 120;
19
+ const PET_RENDER_CACHE_LIMIT = 48;
20
+
21
+ type ThemeLike = {
22
+ fg(color: string, value: string): string;
23
+ };
24
+
25
+ function randomIdleEmoteState(idleState: PetState, random = Math.random): PetState {
26
+ const candidates = (["waving", "jumping"] as const).filter((state) => state !== idleState);
27
+ return candidates[Math.floor(random() * candidates.length)] ?? "waving";
28
+ }
29
+
30
+ function petStateAnimationDurationMs(state: PetState): number {
31
+ return PET_ANIMATION_ROWS[state].durations.reduce((sum, duration) => sum + duration, 0);
32
+ }
33
+
34
+ function petFrameInfo(pet: LoadedCodexPet, state: PetState, elapsedMs: number) {
35
+ const frames = pet.states[state] ?? pet.states.idle;
36
+ const frame = animationFrameAt(frames, elapsedMs);
37
+ return { frame, frameIndex: frame ? frames.indexOf(frame) : -1 };
38
+ }
39
+
40
+ function petFrameRows(
41
+ pet: LoadedCodexPet,
42
+ state: PetState,
43
+ elapsedMs: number,
44
+ width: number,
45
+ sizeCells: number,
46
+ ): number {
47
+ const { frame } = petFrameInfo(pet, state, elapsedMs);
48
+ if (!frame || !getCapabilities().images) return 0;
49
+ const columns = Math.max(1, Math.min(Math.max(1, width - 2), sizeCells));
50
+ return calculateImageRows(
51
+ { widthPx: frame.widthPx, heightPx: frame.heightPx },
52
+ columns,
53
+ getCellDimensions(),
54
+ );
55
+ }
56
+
57
+ function petPlaceholderLines(
58
+ pet: LoadedCodexPet,
59
+ state: PetState,
60
+ elapsedMs: number,
61
+ width: number,
62
+ sizeCells: number,
63
+ ): string[] {
64
+ return Array.from({ length: petFrameRows(pet, state, elapsedMs, width, sizeCells) }, () => "");
65
+ }
66
+
67
+ function petRenderCacheKey(
68
+ pet: LoadedCodexPet,
69
+ state: PetState,
70
+ frameIndex: number,
71
+ placement: PetPlacement,
72
+ width: number,
73
+ sizeCells: number,
74
+ ): string {
75
+ const cellDimensions = getCellDimensions();
76
+ const imageProtocol = getCapabilities().images ?? "none";
77
+ return [
78
+ pet.pet.slug,
79
+ state,
80
+ frameIndex,
81
+ placement,
82
+ width,
83
+ sizeCells,
84
+ imageProtocol,
85
+ cellDimensions.widthPx,
86
+ cellDimensions.heightPx,
87
+ ].join(":");
88
+ }
89
+
90
+ export class PetFooterController {
91
+ private pet: LoadedCodexPet | undefined;
92
+ private petError: string | undefined;
93
+ private petLoadKey: string | undefined;
94
+ private petLoadInFlight = false;
95
+ private petLoadingKey: string | undefined;
96
+ private petLoadNotify = false;
97
+ private queuedPetRefresh: { ctx: ExtensionContext; notify: boolean } | undefined;
98
+ private petTimer: ReturnType<typeof setTimeout> | undefined;
99
+ private petRenderRequestTimer: ReturnType<typeof setTimeout> | undefined;
100
+ private petRuntimeState: PetState = "idle";
101
+ private petPreviewState: PetState | undefined;
102
+ private petSettingsPreviewActive = false;
103
+ settingsPets: CodexPetPackage[] = [];
104
+ private petFlashState: PetState | undefined;
105
+ private petFlashUntil: number | undefined;
106
+ private petFlashTimer: ReturnType<typeof setTimeout> | undefined;
107
+ private petIdleEmoteTimer: ReturnType<typeof setTimeout> | undefined;
108
+ private petResizeTimer: ReturnType<typeof setTimeout> | undefined;
109
+ private petResizeFreezeUntil = 0;
110
+ private stdoutResizeHandler: (() => void) | undefined;
111
+ private petAnimationState: PetState | undefined;
112
+ private petAnimationStartedAt = Date.now();
113
+ private readonly petRenderCache = new Map<string, string[]>();
114
+ private readonly activeToolCallIds = new Set<string>();
115
+ private readonly petImageId = 0x70657401;
116
+ private readonly petKittyManager = new CodexPetKittyManager(this.petImageId);
117
+ private requestFooterRender: (() => void) | undefined;
118
+ private requestSettingsRender: (() => void) | undefined;
119
+ private shuttingDown = false;
120
+
121
+ constructor(
122
+ private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
123
+ private readonly updateFooter: (ctx: ExtensionContext) => void,
124
+ private readonly getFooterInstalled: () => boolean = () => false,
125
+ ) {}
126
+
127
+ get loadedPet(): LoadedCodexPet | undefined {
128
+ return this.pet;
129
+ }
130
+
131
+ get error(): string | undefined {
132
+ return this.petError;
133
+ }
134
+
135
+ get previewState(): PetState | undefined {
136
+ return this.petPreviewState;
137
+ }
138
+
139
+ setPreviewState(state: PetState | undefined): void {
140
+ this.petPreviewState = state;
141
+ }
142
+
143
+ setFooterRenderRequest(request: (() => void) | undefined): void {
144
+ this.requestFooterRender = request;
145
+ }
146
+
147
+ requestFooterRenderNow(): void {
148
+ this.requestFooterRender?.();
149
+ }
150
+
151
+ setSettingsRenderRequest(request: (() => void) | undefined): void {
152
+ this.requestSettingsRender = request;
153
+ }
154
+
155
+ shouldLoadForConfig(cfg: ResolvedConfig): boolean {
156
+ return cfg.pets.enabled || this.petSettingsPreviewActive;
157
+ }
158
+
159
+ shouldRenderInFooter(cfg: ResolvedConfig, footerInstalled = this.getFooterInstalled()): boolean {
160
+ return cfg.pets.enabled || (this.petSettingsPreviewActive && footerInstalled);
161
+ }
162
+
163
+ setSettingsPreviewActive(ctx: ExtensionContext, next: boolean): void {
164
+ if (this.petSettingsPreviewActive === next) return;
165
+ this.petSettingsPreviewActive = next;
166
+ if (!this.getConfig(ctx).pets.enabled) this.petLoadKey = undefined;
167
+ void this.refresh(ctx, this.getConfig(ctx)).then(() => {
168
+ if (this.shuttingDown) return;
169
+ if (!this.shouldRenderInFooter(this.getConfig(ctx))) this.flushKittyCleanupNow();
170
+ this.requestSettingsRender?.();
171
+ });
172
+ this.updateFooter(ctx);
173
+ }
174
+
175
+ clearFlash(): void {
176
+ if (this.petFlashTimer) clearTimeout(this.petFlashTimer);
177
+ this.petFlashTimer = undefined;
178
+ this.petFlashState = undefined;
179
+ this.petFlashUntil = undefined;
180
+ this.petAnimationState = undefined;
181
+ }
182
+
183
+ stopIdleEmotes(): void {
184
+ if (this.petIdleEmoteTimer) clearTimeout(this.petIdleEmoteTimer);
185
+ this.petIdleEmoteTimer = undefined;
186
+ }
187
+
188
+ stopAnimation(): void {
189
+ if (this.petTimer) clearTimeout(this.petTimer);
190
+ this.petTimer = undefined;
191
+ }
192
+
193
+ stopPendingRenderRequest(): void {
194
+ if (this.petRenderRequestTimer) clearTimeout(this.petRenderRequestTimer);
195
+ this.petRenderRequestTimer = undefined;
196
+ }
197
+
198
+ resetRenderCache(): void {
199
+ this.petRenderCache.clear();
200
+ }
201
+
202
+ invalidateLoadKey(): void {
203
+ this.petLoadKey = undefined;
204
+ }
205
+
206
+ queueKittyCleanup(target = this.pet): void {
207
+ this.petKittyManager.invalidate(target);
208
+ }
209
+
210
+ private takeKittyCleanupSequence(): string {
211
+ return this.petKittyManager.takeCleanupSequence();
212
+ }
213
+
214
+ withPendingKittyCleanup(lines: string[]): string[] {
215
+ const sequence = this.takeKittyCleanupSequence();
216
+ if (!sequence) return lines;
217
+ if (lines.length === 0) return [sequence];
218
+ return [`${sequence}\x1b[0m${lines[0]}\x1b[0m`, ...lines.slice(1)];
219
+ }
220
+
221
+ private writeKittySequence(sequence: string): void {
222
+ if (sequence && process.stdout.isTTY) process.stdout.write(sequence);
223
+ }
224
+
225
+ flushKittyCleanupNow(): void {
226
+ this.writeKittySequence(this.takeKittyCleanupSequence());
227
+ }
228
+
229
+ disposeKittyNow(target = this.pet): void {
230
+ this.writeKittySequence(this.petKittyManager.dispose(target));
231
+ }
232
+
233
+ private rememberRender(key: string, lines: string[]): void {
234
+ this.petRenderCache.set(key, lines);
235
+ while (this.petRenderCache.size > PET_RENDER_CACHE_LIMIT) {
236
+ const firstKey = this.petRenderCache.keys().next().value;
237
+ if (firstKey === undefined) break;
238
+ this.petRenderCache.delete(firstKey);
239
+ }
240
+ }
241
+
242
+ isResizeFrozen(now = Date.now()): boolean {
243
+ return now < this.petResizeFreezeUntil;
244
+ }
245
+
246
+ private clearResizeFreeze(): void {
247
+ if (this.petResizeTimer) clearTimeout(this.petResizeTimer);
248
+ this.petResizeTimer = undefined;
249
+ this.petResizeFreezeUntil = 0;
250
+ }
251
+
252
+ freezeForResize(ctx: ExtensionContext, now = Date.now()): void {
253
+ this.petResizeFreezeUntil = now + PET_RESIZE_FREEZE_MS;
254
+ this.queueKittyCleanup();
255
+ this.stopAnimation();
256
+ this.stopIdleEmotes();
257
+ this.stopPendingRenderRequest();
258
+ if (this.petResizeTimer) clearTimeout(this.petResizeTimer);
259
+ this.petResizeTimer = setTimeout(() => {
260
+ this.petResizeTimer = undefined;
261
+ this.petResizeFreezeUntil = 0;
262
+ this.petKittyManager.resetForResize(this.pet);
263
+ this.resetRenderCache();
264
+ this.updateFooter(ctx);
265
+ }, PET_RESIZE_FREEZE_MS);
266
+ this.petResizeTimer.unref?.();
267
+ }
268
+
269
+ installResizeGuard(ctx: ExtensionContext): void {
270
+ if (this.stdoutResizeHandler || !process.stdout.isTTY) return;
271
+ this.stdoutResizeHandler = () => this.freezeForResize(ctx);
272
+ process.stdout.on("resize", this.stdoutResizeHandler);
273
+ }
274
+
275
+ uninstallResizeGuard(): void {
276
+ if (this.stdoutResizeHandler) process.stdout.off("resize", this.stdoutResizeHandler);
277
+ this.stdoutResizeHandler = undefined;
278
+ this.clearResizeFreeze();
279
+ }
280
+
281
+ private currentState(ctx: ExtensionContext, cfg = this.getConfig(ctx)): PetState {
282
+ if (this.petPreviewState) return this.petPreviewState;
283
+ const now = Date.now();
284
+ if (this.petFlashState && this.petFlashUntil !== undefined && now < this.petFlashUntil)
285
+ return this.petFlashState;
286
+ if (this.petFlashState) this.clearFlash();
287
+ return this.petRuntimeState === "idle" ? cfg.pets.state : this.petRuntimeState;
288
+ }
289
+
290
+ private currentAnimation(ctx: ExtensionContext, cfg = this.getConfig(ctx)) {
291
+ const state = this.currentState(ctx, cfg);
292
+ const now = Date.now();
293
+ if (state !== this.petAnimationState) {
294
+ this.petAnimationState = state;
295
+ this.petAnimationStartedAt = now;
296
+ }
297
+ return { state, elapsedMs: now - this.petAnimationStartedAt };
298
+ }
299
+
300
+ private requestFooterAndSettingsRender(): void {
301
+ if (this.petRenderRequestTimer) return;
302
+ this.petRenderRequestTimer = setTimeout(() => {
303
+ this.petRenderRequestTimer = undefined;
304
+ this.requestFooterRender?.();
305
+ this.requestSettingsRender?.();
306
+ }, 16);
307
+ this.petRenderRequestTimer.unref?.();
308
+ }
309
+
310
+ private scheduleAnimation(ctx: ExtensionContext): void {
311
+ if (!this.pet || this.petTimer || this.isResizeFrozen() || !getCapabilities().images) return;
312
+ const { state, elapsedMs } = this.currentAnimation(ctx);
313
+ const frames = this.pet.states[state] ?? this.pet.states.idle;
314
+ this.petTimer = setTimeout(
315
+ () => {
316
+ this.petTimer = undefined;
317
+ if (this.isResizeFrozen()) return;
318
+ this.requestFooterAndSettingsRender();
319
+ this.scheduleAnimation(ctx);
320
+ },
321
+ nextAnimationFrameDelayMs(frames, elapsedMs),
322
+ );
323
+ this.petTimer.unref?.();
324
+ }
325
+
326
+ startAnimation(ctx: ExtensionContext): void {
327
+ this.scheduleAnimation(ctx);
328
+ void this.refresh(ctx);
329
+ }
330
+
331
+ private shouldRunIdleEmotes(ctx: ExtensionContext, cfg = this.getConfig(ctx)): boolean {
332
+ return (
333
+ cfg.pets.enabled &&
334
+ cfg.pets.idleEmotes &&
335
+ getCapabilities().images !== null &&
336
+ this.pet !== undefined &&
337
+ this.petRuntimeState === "idle" &&
338
+ this.activeToolCallIds.size === 0 &&
339
+ this.petPreviewState === undefined &&
340
+ !this.petSettingsPreviewActive &&
341
+ this.petFlashState === undefined &&
342
+ !this.isResizeFrozen()
343
+ );
344
+ }
345
+
346
+ playFlash(ctx: ExtensionContext, state: PetState, cfg = this.getConfig(ctx)): void {
347
+ if (!cfg.pets.enabled || !getCapabilities().images) return;
348
+ this.clearFlash();
349
+ const durationMs = petStateAnimationDurationMs(state);
350
+ this.petFlashState = state;
351
+ this.petFlashUntil = Date.now() + durationMs;
352
+ this.petFlashTimer = setTimeout(() => {
353
+ this.petFlashTimer = undefined;
354
+ this.petFlashState = undefined;
355
+ this.petFlashUntil = undefined;
356
+ this.petAnimationState = undefined;
357
+ this.updateFooter(ctx);
358
+ }, durationMs);
359
+ this.petFlashTimer.unref?.();
360
+ this.updateFooter(ctx);
361
+ }
362
+
363
+ scheduleIdleEmote(ctx: ExtensionContext, cfg = this.getConfig(ctx)): void {
364
+ if (this.petIdleEmoteTimer || !this.shouldRunIdleEmotes(ctx, cfg)) return;
365
+ const delayMs = Math.round(cfg.pets.idleEmoteIntervalMs * (0.75 + Math.random() * 0.75));
366
+ this.petIdleEmoteTimer = setTimeout(() => {
367
+ this.petIdleEmoteTimer = undefined;
368
+ const nextConfig = this.getConfig(ctx);
369
+ if (this.shouldRunIdleEmotes(ctx, nextConfig)) {
370
+ this.playFlash(ctx, randomIdleEmoteState(nextConfig.pets.state), nextConfig);
371
+ }
372
+ this.scheduleIdleEmote(ctx, nextConfig);
373
+ }, delayMs);
374
+ this.petIdleEmoteTimer.unref?.();
375
+ }
376
+
377
+ updateActivity(ctx: ExtensionContext, cfg: ResolvedConfig): void {
378
+ const resizeFrozen = this.isResizeFrozen();
379
+ const shouldAnimatePet = this.shouldLoadForConfig(cfg);
380
+ if (!shouldAnimatePet || resizeFrozen) {
381
+ this.stopAnimation();
382
+ } else {
383
+ if (this.currentState(ctx, cfg) !== this.petAnimationState) this.stopAnimation();
384
+ this.startAnimation(ctx);
385
+ }
386
+ if (!resizeFrozen && this.shouldRunIdleEmotes(ctx, cfg)) this.scheduleIdleEmote(ctx, cfg);
387
+ else this.stopIdleEmotes();
388
+ }
389
+
390
+ private loadKeyForConfig(cfg: ResolvedConfig): string {
391
+ const cellDimensions = getCellDimensions();
392
+ return [
393
+ cfg.pets.slug || "__first__",
394
+ cfg.pets.sizeCells,
395
+ getCapabilities().images ?? "none",
396
+ cellDimensions.widthPx,
397
+ cellDimensions.heightPx,
398
+ ].join(":");
399
+ }
400
+
401
+ async refresh(ctx: ExtensionContext, cfg = this.getConfig(ctx), notify = false): Promise<void> {
402
+ if (this.shuttingDown) return;
403
+ if (!this.shouldLoadForConfig(cfg)) {
404
+ this.queuedPetRefresh = undefined;
405
+ this.queueKittyCleanup();
406
+ this.pet = undefined;
407
+ this.petError = undefined;
408
+ this.petLoadKey = undefined;
409
+ this.resetRenderCache();
410
+ this.clearFlash();
411
+ this.stopIdleEmotes();
412
+ this.stopAnimation();
413
+ return;
414
+ }
415
+
416
+ const key = this.loadKeyForConfig(cfg);
417
+ if (this.petLoadKey === key) return;
418
+ if (this.petLoadInFlight) {
419
+ if (this.petLoadingKey === key) this.petLoadNotify ||= notify;
420
+ else this.queuedPetRefresh = { ctx, notify: this.queuedPetRefresh?.notify || notify };
421
+ return;
422
+ }
423
+
424
+ this.petLoadInFlight = true;
425
+ this.petLoadingKey = key;
426
+ this.petLoadNotify = notify;
427
+ this.petError = undefined;
428
+ const previousPet = this.pet;
429
+
430
+ const shouldApplyLoadResult = (): boolean => {
431
+ if (this.shuttingDown || this.queuedPetRefresh) return false;
432
+ const latestConfig = this.getConfig(ctx);
433
+ return this.shouldLoadForConfig(latestConfig) && this.loadKeyForConfig(latestConfig) === key;
434
+ };
435
+
436
+ try {
437
+ const loadedPet = await loadCodexPet(cfg.pets.slug || undefined, undefined, {
438
+ sizeCells: cfg.pets.sizeCells,
439
+ });
440
+ if (!shouldApplyLoadResult()) return;
441
+ if (previousPet) this.queueKittyCleanup(previousPet);
442
+ this.pet = loadedPet;
443
+ const petIssue = this.pet
444
+ ? undefined
445
+ : describeCodexPetSelectionIssue(await listCodexPets(), cfg.pets.slug || undefined);
446
+ this.petAnimationState = undefined;
447
+ this.resetRenderCache();
448
+ this.petLoadKey = key;
449
+ if (!this.pet && petIssue) this.petError = petIssue.short;
450
+ if (this.petLoadNotify) {
451
+ ctx.ui.notify(
452
+ this.pet
453
+ ? `Rendering ${this.pet.pet.name} in the Better OpenAI footer.`
454
+ : (petIssue?.message ?? "No ready custom Codex pet found."),
455
+ this.pet ? "info" : "warning",
456
+ );
457
+ }
458
+ } catch (error) {
459
+ if (!shouldApplyLoadResult()) return;
460
+ if (previousPet) this.queueKittyCleanup(previousPet);
461
+ this.pet = undefined;
462
+ this.petLoadKey = key;
463
+ this.petError = error instanceof Error ? error.message : String(error);
464
+ if (this.petLoadNotify)
465
+ ctx.ui.notify(`Could not render Codex pet: ${this.petError}`, "warning");
466
+ } finally {
467
+ this.petLoadInFlight = false;
468
+ this.petLoadingKey = undefined;
469
+ this.petLoadNotify = false;
470
+ const queued = this.queuedPetRefresh;
471
+ this.queuedPetRefresh = undefined;
472
+ if (queued && !this.shuttingDown)
473
+ void this.refresh(queued.ctx, this.getConfig(queued.ctx), queued.notify);
474
+ if (!this.shuttingDown) this.updateFooter(ctx);
475
+ }
476
+ }
477
+
478
+ agentStart(ctx: ExtensionContext): void {
479
+ this.activeToolCallIds.clear();
480
+ this.clearFlash();
481
+ this.petRuntimeState = this.getConfig(ctx).pets.thinkingState;
482
+ }
483
+
484
+ toolStart(ctx: ExtensionContext, toolCallId: string): void {
485
+ this.activeToolCallIds.add(toolCallId);
486
+ this.petRuntimeState = this.getConfig(ctx).pets.toolState;
487
+ }
488
+
489
+ toolEnd(ctx: ExtensionContext, toolCallId: string, isError: boolean): void {
490
+ this.activeToolCallIds.delete(toolCallId);
491
+ const cfg = this.getConfig(ctx);
492
+ this.petRuntimeState =
493
+ this.activeToolCallIds.size > 0 ? cfg.pets.toolState : cfg.pets.thinkingState;
494
+ if (isError) this.playFlash(ctx, cfg.pets.failedToolState, cfg);
495
+ }
496
+
497
+ agentEnd(): void {
498
+ this.activeToolCallIds.clear();
499
+ this.petRuntimeState = "idle";
500
+ }
501
+
502
+ tuck(): void {
503
+ this.queueKittyCleanup();
504
+ this.pet = undefined;
505
+ this.petError = undefined;
506
+ this.resetRenderCache();
507
+ this.clearFlash();
508
+ this.stopIdleEmotes();
509
+ this.stopAnimation();
510
+ }
511
+
512
+ renderSettingsPreview(ctx: ExtensionContext, width: number): string[] {
513
+ if (!this.petSettingsPreviewActive || this.shouldRenderInFooter(this.getConfig(ctx))) return [];
514
+ if (!this.pet) {
515
+ return [this.petError ? `Preview unavailable: ${this.petError}` : "Preview: loading pet…"];
516
+ }
517
+ const cfg = this.getConfig(ctx);
518
+ const { state, elapsedMs } = this.currentAnimation(ctx, cfg);
519
+ const plainTheme = { fg: (_color: string, value: string) => value };
520
+ return [
521
+ `Preview: ${this.pet.pet.name}`,
522
+ ...renderCodexPetFrame(this.pet, state, width, plainTheme, {
523
+ sizeCells: cfg.pets.sizeCells,
524
+ imageId: this.petImageId,
525
+ now: elapsedMs,
526
+ durationMultiplier: 1,
527
+ kittyManager: this.petKittyManager,
528
+ }),
529
+ ];
530
+ }
531
+
532
+ renderPetLines(
533
+ ctx: ExtensionContext,
534
+ cfg: ResolvedConfig,
535
+ options: {
536
+ shouldRenderPet: boolean;
537
+ freezePetFrame: boolean;
538
+ requestedPetPlacement: PetPlacement;
539
+ petColumnWidth: number;
540
+ petRenderSizeCells: number;
541
+ width: number;
542
+ theme: ThemeLike;
543
+ },
544
+ ): string[] {
545
+ const petLines: string[] = [];
546
+ if (!options.shouldRenderPet) return petLines;
547
+ if (this.pet) {
548
+ const { state: petState, elapsedMs } = this.currentAnimation(ctx, cfg);
549
+ if (options.freezePetFrame) {
550
+ petLines.push(
551
+ ...petPlaceholderLines(
552
+ this.pet,
553
+ petState,
554
+ elapsedMs,
555
+ options.petColumnWidth,
556
+ options.petRenderSizeCells,
557
+ ),
558
+ );
559
+ } else {
560
+ const imageProtocol = getCapabilities().images;
561
+ if (imageProtocol !== null && imageProtocol !== "kitty") {
562
+ const { frameIndex } = petFrameInfo(this.pet, petState, elapsedMs);
563
+ const cacheKey = petRenderCacheKey(
564
+ this.pet,
565
+ petState,
566
+ frameIndex,
567
+ options.requestedPetPlacement,
568
+ options.petColumnWidth,
569
+ options.petRenderSizeCells,
570
+ );
571
+ const cachedPetLines = this.petRenderCache.get(cacheKey);
572
+ if (cachedPetLines) {
573
+ petLines.push(...cachedPetLines);
574
+ return petLines;
575
+ }
576
+ const renderedPetLines = renderCodexPetFrame(
577
+ this.pet,
578
+ petState,
579
+ options.petColumnWidth,
580
+ options.theme,
581
+ {
582
+ sizeCells: options.petRenderSizeCells,
583
+ imageId: this.petImageId,
584
+ now: elapsedMs,
585
+ durationMultiplier: 1,
586
+ kittyManager: this.petKittyManager,
587
+ },
588
+ );
589
+ petLines.push(...renderedPetLines);
590
+ if (renderedPetLines.length > 0) this.rememberRender(cacheKey, renderedPetLines);
591
+ } else {
592
+ petLines.push(
593
+ ...renderCodexPetFrame(this.pet, petState, options.petColumnWidth, options.theme, {
594
+ sizeCells: options.petRenderSizeCells,
595
+ imageId: this.petImageId,
596
+ now: elapsedMs,
597
+ durationMultiplier: 1,
598
+ kittyManager: this.petKittyManager,
599
+ }),
600
+ );
601
+ }
602
+ }
603
+ } else if (this.petError) {
604
+ petLines.push(
605
+ truncateToWidth(options.theme.fg("warning", `pet: ${this.petError}`), options.width, "..."),
606
+ );
607
+ }
608
+ return petLines;
609
+ }
610
+
611
+ shutdown(): void {
612
+ this.shuttingDown = true;
613
+ this.queuedPetRefresh = undefined;
614
+ this.petLoadingKey = undefined;
615
+ this.petLoadNotify = false;
616
+ this.activeToolCallIds.clear();
617
+ this.uninstallResizeGuard();
618
+ this.disposeKittyNow();
619
+ this.resetRenderCache();
620
+ this.clearFlash();
621
+ this.stopIdleEmotes();
622
+ this.stopAnimation();
623
+ this.stopPendingRenderRequest();
624
+ this.requestFooterRender = undefined;
625
+ this.requestSettingsRender = undefined;
626
+ }
627
+ }