pi-better-openai 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.ts +214 -74
  2. package/package.json +1 -1
  3. package/src/pets.ts +207 -73
package/index.ts CHANGED
@@ -66,10 +66,9 @@ import {
66
66
  listCodexPets,
67
67
  nextAnimationFrameDelayMs,
68
68
  PET_ANIMATION_ROWS,
69
- deleteCodexPetKittyImage,
69
+ CodexPetKittyManager,
70
70
  registerOpenAIPets,
71
71
  renderCodexPetFrame,
72
- resetCodexPetKittyCache,
73
72
  _petsTest,
74
73
  } from "./src/pets.ts";
75
74
 
@@ -207,7 +206,10 @@ function formatPetSelectPrompt(
207
206
  lines.push(
208
207
  "",
209
208
  "Not ready:",
210
- ...brokenPets.map((pet) => `- ${pet.slug} (${pet.name}) — missing ${pet.spritesheetPath}`),
209
+ ...brokenPets.map(
210
+ (pet) =>
211
+ `- ${pet.slug} (${pet.name}) — ${pet.spritesheetIssue ?? `missing ${pet.spritesheetPath}`}`,
212
+ ),
211
213
  );
212
214
  }
213
215
  return { message: lines.join("\n"), level: "info" };
@@ -391,9 +393,9 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
391
393
  let petAnimationState: PetState | undefined;
392
394
  let petAnimationStartedAt = Date.now();
393
395
  const petRenderCache = new Map<string, string[]>();
394
- const pendingPetKittyCleanupImageIds = new Set<number>();
395
396
  const activeToolCallIds = new Set<string>();
396
397
  const petImageId = 0x70657401;
398
+ const petKittyManager = new CodexPetKittyManager(petImageId);
397
399
 
398
400
  function refresh(ctx: ExtensionContext): ResolvedConfig {
399
401
  cachedConfig = resolveConfig(ctx.cwd || process.cwd());
@@ -455,22 +457,11 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
455
457
  }
456
458
 
457
459
  function queuePetKittyCleanup(target = pet): void {
458
- if (!target) return;
459
- for (const frames of Object.values(target.states)) {
460
- for (const frame of frames) {
461
- if (frame.kittyImageId !== undefined)
462
- pendingPetKittyCleanupImageIds.add(frame.kittyImageId);
463
- }
464
- }
460
+ petKittyManager.invalidate(target);
465
461
  }
466
462
 
467
463
  function takePetKittyCleanupSequence(): string {
468
- if (pendingPetKittyCleanupImageIds.size === 0) return "";
469
- const sequence = Array.from(pendingPetKittyCleanupImageIds)
470
- .map((imageId) => deleteCodexPetKittyImage(imageId))
471
- .join("");
472
- pendingPetKittyCleanupImageIds.clear();
473
- return sequence;
464
+ return petKittyManager.takeCleanupSequence();
474
465
  }
475
466
 
476
467
  function withPendingPetKittyCleanup(lines: string[]): string[] {
@@ -480,11 +471,18 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
480
471
  return [`${sequence}\x1b[0m${lines[0]}\x1b[0m`, ...lines.slice(1)];
481
472
  }
482
473
 
483
- function flushPetKittyCleanupNow(): void {
484
- const sequence = takePetKittyCleanupSequence();
474
+ function writePetKittySequence(sequence: string): void {
485
475
  if (sequence && process.stdout.isTTY) process.stdout.write(sequence);
486
476
  }
487
477
 
478
+ function flushPetKittyCleanupNow(): void {
479
+ writePetKittySequence(takePetKittyCleanupSequence());
480
+ }
481
+
482
+ function disposePetKittyNow(target = pet): void {
483
+ writePetKittySequence(petKittyManager.dispose(target));
484
+ }
485
+
488
486
  function rememberPetRender(key: string, lines: string[]): void {
489
487
  petRenderCache.set(key, lines);
490
488
  while (petRenderCache.size > PET_RENDER_CACHE_LIMIT) {
@@ -514,7 +512,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
514
512
  petResizeTimer = setTimeout(() => {
515
513
  petResizeTimer = undefined;
516
514
  petResizeFreezeUntil = 0;
517
- resetCodexPetKittyCache(pet, petImageId);
515
+ petKittyManager.resetForResize(pet);
518
516
  resetPetRenderCache();
519
517
  updateFooter(ctx);
520
518
  }, PET_RESIZE_FREEZE_MS);
@@ -658,7 +656,6 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
658
656
  if (!shouldLoadPetForConfig(cfg)) {
659
657
  queuedPetRefresh = undefined;
660
658
  queuePetKittyCleanup();
661
- resetCodexPetKittyCache(pet, petImageId);
662
659
  pet = undefined;
663
660
  petError = undefined;
664
661
  petLoadKey = undefined;
@@ -694,10 +691,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
694
691
  sizeCells: cfg.pets.sizeCells,
695
692
  });
696
693
  if (!shouldApplyLoadResult()) return;
697
- if (previousPet) {
698
- queuePetKittyCleanup(previousPet);
699
- resetCodexPetKittyCache(previousPet, petImageId);
700
- }
694
+ if (previousPet) queuePetKittyCleanup(previousPet);
701
695
  pet = loadedPet;
702
696
  const petIssue = pet
703
697
  ? undefined
@@ -716,10 +710,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
716
710
  }
717
711
  } catch (error) {
718
712
  if (!shouldApplyLoadResult()) return;
719
- if (previousPet) {
720
- queuePetKittyCleanup(previousPet);
721
- resetCodexPetKittyCache(previousPet, petImageId);
722
- }
713
+ if (previousPet) queuePetKittyCleanup(previousPet);
723
714
  pet = undefined;
724
715
  petLoadKey = key;
725
716
  petError = error instanceof Error ? error.message : String(error);
@@ -1038,6 +1029,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1038
1029
  imageId: petImageId,
1039
1030
  now: elapsedMs,
1040
1031
  durationMultiplier: 1,
1032
+ kittyManager: petKittyManager,
1041
1033
  }),
1042
1034
  ];
1043
1035
  }
@@ -1057,6 +1049,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1057
1049
  let selectedIndex = 0;
1058
1050
  let searchQuery = "";
1059
1051
  let closed = false;
1052
+ let submenuComponent: ReturnType<NonNullable<SettingsPickerItem["submenu"]>> | undefined;
1053
+ let submenuItemIndex: number | undefined;
1060
1054
 
1061
1055
  function currentItems(): SettingsPickerItem[] {
1062
1056
  const allItems = items();
@@ -1078,6 +1072,13 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1078
1072
  done();
1079
1073
  }
1080
1074
 
1075
+ function closeNestedSubmenu(): void {
1076
+ submenuComponent = undefined;
1077
+ if (submenuItemIndex !== undefined) selectedIndex = submenuItemIndex;
1078
+ submenuItemIndex = undefined;
1079
+ options?.onSelection?.(selectedItem());
1080
+ }
1081
+
1081
1082
  function cycleSelected(direction: 1 | -1 = 1): void {
1082
1083
  const item = selectedItem();
1083
1084
  if (!item?.values?.length) return;
@@ -1090,8 +1091,23 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1090
1091
  options?.onSelection?.(selectedItem());
1091
1092
  }
1092
1093
 
1094
+ function activateSelected(): void {
1095
+ const item = selectedItem();
1096
+ if (!item) return;
1097
+ if (item.submenu) {
1098
+ submenuItemIndex = selectedIndex;
1099
+ submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
1100
+ if (selectedValue !== undefined) writeSetting(ctx, item.id, selectedValue);
1101
+ closeNestedSubmenu();
1102
+ });
1103
+ return;
1104
+ }
1105
+ cycleSelected(1);
1106
+ }
1107
+
1093
1108
  return {
1094
1109
  render(width: number) {
1110
+ if (submenuComponent) return submenuComponent.render(width);
1095
1111
  const current = currentItems();
1096
1112
  const selected = selectedItem();
1097
1113
  if (!closed) options?.onSelection?.(selected);
@@ -1149,8 +1165,14 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1149
1165
  );
1150
1166
  return lines;
1151
1167
  },
1152
- invalidate() {},
1168
+ invalidate() {
1169
+ submenuComponent?.invalidate();
1170
+ },
1153
1171
  handleInput(data: string) {
1172
+ if (submenuComponent) {
1173
+ submenuComponent.handleInput?.(data);
1174
+ return;
1175
+ }
1154
1176
  const current = currentItems();
1155
1177
  if (current.length === 0) {
1156
1178
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) close();
@@ -1168,7 +1190,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1168
1190
  matchesKey(data, Key.space) ||
1169
1191
  data === " "
1170
1192
  )
1171
- cycleSelected(1);
1193
+ activateSelected();
1172
1194
  else if (matchesKey(data, Key.left)) cycleSelected(-1);
1173
1195
  else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) close();
1174
1196
  else if (matchesKey(data, Key.backspace)) {
@@ -1183,7 +1205,35 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1183
1205
  };
1184
1206
  }
1185
1207
 
1186
- function buildSettingsItems(ctx: ExtensionContext, cfg: ResolvedConfig): SettingsPickerItem[] {
1208
+ function fastSettingsSummary(ctx: ExtensionContext, cfg: ResolvedConfig): string {
1209
+ if (active) return "on";
1210
+ if (desiredActive)
1211
+ return supportsFast(ctx, cfg.supportedModels) ? "requested" : "requested inactive";
1212
+ return "off";
1213
+ }
1214
+
1215
+ function usageSettingsSummary(cfg: ResolvedConfig): string {
1216
+ return cfg.usage.enabled
1217
+ ? `enabled · ${Math.round(cfg.usage.refreshIntervalMs / 1000)}s`
1218
+ : "disabled";
1219
+ }
1220
+
1221
+ function imageSettingsSummary(cfg: ResolvedConfig): string {
1222
+ return cfg.image.enabled
1223
+ ? `enabled · ${cfg.image.defaultModel} · ${cfg.image.defaultSave}/${cfg.image.outputFormat}`
1224
+ : "disabled";
1225
+ }
1226
+
1227
+ function petSettingsSummary(cfg: ResolvedConfig): string {
1228
+ const selected = cfg.pets.slug || (cfg.pets.enabled ? "first ready" : PET_EMPTY_VALUE);
1229
+ const status = cfg.pets.enabled ? "enabled" : "disabled";
1230
+ return `${status} · ${selected} · ${cfg.pets.placement}`;
1231
+ }
1232
+
1233
+ function buildFastSettingsItems(
1234
+ ctx: ExtensionContext,
1235
+ cfg: ResolvedConfig,
1236
+ ): SettingsPickerItem[] {
1187
1237
  return [
1188
1238
  {
1189
1239
  id: "fast.enabled",
@@ -1199,6 +1249,11 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1199
1249
  values: ["true", "false"],
1200
1250
  description: "Remember fast-mode state across sessions.",
1201
1251
  },
1252
+ ];
1253
+ }
1254
+
1255
+ function buildFooterSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
1256
+ return [
1202
1257
  {
1203
1258
  id: "footer.mode",
1204
1259
  label: "Footer mode",
@@ -1207,36 +1262,11 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1207
1262
  description:
1208
1263
  "replace = custom footer, status = pi footer plus status line, off = no Better OpenAI footer/status unless Footer pet is enabled.",
1209
1264
  },
1210
- {
1211
- id: "pets.menu",
1212
- label: "Footer pet",
1213
- currentValue: "configure",
1214
- description: "Configure footer pet visibility, animation-state mapping, and size.",
1215
- submenu: (_value, done) => {
1216
- setPetSettingsPreviewActive(ctx, true);
1217
- return settingsSubmenu(
1218
- "Footer pet settings",
1219
- () => buildPetSettingsItems(config(ctx)),
1220
- ctx,
1221
- () => done(),
1222
- {
1223
- onSelection: (item) => {
1224
- const previewState = petPreviewFromItem(item);
1225
- if (previewState !== petPreviewState) {
1226
- petPreviewState = previewState;
1227
- updateFooter(ctx);
1228
- }
1229
- },
1230
- onClose: () => {
1231
- petPreviewState = undefined;
1232
- setPetSettingsPreviewActive(ctx, false);
1233
- updateFooter(ctx);
1234
- },
1235
- renderExtra: (width) => renderPetSettingsPreview(ctx, width),
1236
- },
1237
- );
1238
- },
1239
- },
1265
+ ];
1266
+ }
1267
+
1268
+ function buildUsageSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
1269
+ return [
1240
1270
  {
1241
1271
  id: "usage.enabled",
1242
1272
  label: "Usage display",
@@ -1265,6 +1295,11 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1265
1295
  values: ["true", "false"],
1266
1296
  description: "Include compact reset countdowns and local reset times.",
1267
1297
  },
1298
+ ];
1299
+ }
1300
+
1301
+ function buildImageSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
1302
+ return [
1268
1303
  {
1269
1304
  id: "image.enabled",
1270
1305
  label: "Image tool",
@@ -1301,6 +1336,14 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1301
1336
  values: ["30000", "60000", "120000", "180000", "300000"],
1302
1337
  description: "Image request timeout in milliseconds.",
1303
1338
  },
1339
+ ];
1340
+ }
1341
+
1342
+ function buildDiagnosticsSettingsItems(
1343
+ ctx: ExtensionContext,
1344
+ cfg: ResolvedConfig,
1345
+ ): SettingsPickerItem[] {
1346
+ return [
1304
1347
  {
1305
1348
  id: "debug",
1306
1349
  label: "Debug info",
@@ -1330,6 +1373,106 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1330
1373
  ];
1331
1374
  }
1332
1375
 
1376
+ function buildSettingsSections(ctx: ExtensionContext, cfg: ResolvedConfig): SettingsPickerItem[] {
1377
+ return [
1378
+ {
1379
+ id: "section.fast",
1380
+ label: "Fast mode",
1381
+ currentValue: fastSettingsSummary(ctx, cfg),
1382
+ description: "Configure OpenAI fast mode and persistence.",
1383
+ submenu: (_value, done) =>
1384
+ settingsSubmenu(
1385
+ "Fast mode settings",
1386
+ () => buildFastSettingsItems(ctx, config(ctx)),
1387
+ ctx,
1388
+ () => done(fastSettingsSummary(ctx, config(ctx))),
1389
+ ),
1390
+ },
1391
+ {
1392
+ id: "section.footer",
1393
+ label: "Footer",
1394
+ currentValue: cfg.footer.mode,
1395
+ description: "Configure Better OpenAI footer ownership.",
1396
+ submenu: (_value, done) =>
1397
+ settingsSubmenu(
1398
+ "Footer settings",
1399
+ () => buildFooterSettingsItems(config(ctx)),
1400
+ ctx,
1401
+ () => done(config(ctx).footer.mode),
1402
+ ),
1403
+ },
1404
+ {
1405
+ id: "section.usage",
1406
+ label: "Usage",
1407
+ currentValue: usageSettingsSummary(cfg),
1408
+ description: "Configure subscription usage fetching and display.",
1409
+ submenu: (_value, done) =>
1410
+ settingsSubmenu(
1411
+ "Usage settings",
1412
+ () => buildUsageSettingsItems(config(ctx)),
1413
+ ctx,
1414
+ () => done(usageSettingsSummary(config(ctx))),
1415
+ ),
1416
+ },
1417
+ {
1418
+ id: "section.image",
1419
+ label: "Image tool",
1420
+ currentValue: imageSettingsSummary(cfg),
1421
+ description: "Configure OpenAI image generation defaults.",
1422
+ submenu: (_value, done) =>
1423
+ settingsSubmenu(
1424
+ "Image tool settings",
1425
+ () => buildImageSettingsItems(config(ctx)),
1426
+ ctx,
1427
+ () => done(imageSettingsSummary(config(ctx))),
1428
+ ),
1429
+ },
1430
+ {
1431
+ id: "section.pets",
1432
+ label: "Footer pet",
1433
+ currentValue: petSettingsSummary(cfg),
1434
+ description: "Configure footer pet visibility, animation-state mapping, and size.",
1435
+ submenu: (_value, done) => {
1436
+ setPetSettingsPreviewActive(ctx, true);
1437
+ return settingsSubmenu(
1438
+ "Footer pet settings",
1439
+ () => buildPetSettingsItems(config(ctx)),
1440
+ ctx,
1441
+ () => done(petSettingsSummary(config(ctx))),
1442
+ {
1443
+ onSelection: (item) => {
1444
+ const previewState = petPreviewFromItem(item);
1445
+ if (previewState !== petPreviewState) {
1446
+ petPreviewState = previewState;
1447
+ updateFooter(ctx);
1448
+ }
1449
+ },
1450
+ onClose: () => {
1451
+ petPreviewState = undefined;
1452
+ setPetSettingsPreviewActive(ctx, false);
1453
+ updateFooter(ctx);
1454
+ },
1455
+ renderExtra: (width) => renderPetSettingsPreview(ctx, width),
1456
+ },
1457
+ );
1458
+ },
1459
+ },
1460
+ {
1461
+ id: "section.diagnostics",
1462
+ label: "Diagnostics",
1463
+ currentValue: "debug / config",
1464
+ description: "Show Better OpenAI diagnostics and raw config details.",
1465
+ submenu: (_value, done) =>
1466
+ settingsSubmenu(
1467
+ "Diagnostics",
1468
+ () => buildDiagnosticsSettingsItems(ctx, config(ctx)),
1469
+ ctx,
1470
+ () => done("debug / config"),
1471
+ ),
1472
+ },
1473
+ ];
1474
+ }
1475
+
1333
1476
  function writeSetting(ctx: ExtensionContext, id: string, rawValue: string): void {
1334
1477
  const cfg = refresh(ctx);
1335
1478
  const current = readRawConfig(cfg.configPath);
@@ -1422,15 +1565,15 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1422
1565
  })(),
1423
1566
  );
1424
1567
  const settingsList = new SettingsList(
1425
- buildSettingsItems(ctx, refresh(ctx)),
1426
- 13,
1568
+ buildSettingsSections(ctx, refresh(ctx)),
1569
+ 8,
1427
1570
  getSettingsListTheme(),
1428
1571
  (id, newValue) => {
1429
- writeSetting(ctx, id, newValue);
1572
+ if (!id.startsWith("section.")) writeSetting(ctx, id, newValue);
1430
1573
  settingsList.updateValue(
1431
1574
  id,
1432
- buildSettingsItems(ctx, config(ctx)).find((item) => item.id === id)?.currentValue ??
1433
- newValue,
1575
+ buildSettingsSections(ctx, config(ctx)).find((item) => item.id === id)
1576
+ ?.currentValue ?? newValue,
1434
1577
  );
1435
1578
  tui.requestRender();
1436
1579
  },
@@ -1490,7 +1633,6 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1490
1633
  tuck: (ctx) => {
1491
1634
  writePetConfig(ctx, { enabled: false });
1492
1635
  queuePetKittyCleanup();
1493
- resetCodexPetKittyCache(pet, petImageId);
1494
1636
  pet = undefined;
1495
1637
  petError = undefined;
1496
1638
  resetPetRenderCache();
@@ -1543,12 +1685,12 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1543
1685
  stopPetIdleEmotes();
1544
1686
  stopPetAnimation();
1545
1687
  stopPendingPetRenderRequest();
1688
+ disposePetKittyNow();
1546
1689
  footerInstalled = false;
1547
1690
  requestFooterRender = undefined;
1548
1691
  },
1549
1692
  invalidate() {
1550
1693
  queuePetKittyCleanup();
1551
- resetCodexPetKittyCache(pet, petImageId);
1552
1694
  resetPetRenderCache();
1553
1695
  },
1554
1696
  render(width: number): string[] {
@@ -1732,6 +1874,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1732
1874
  imageId: petImageId,
1733
1875
  now: elapsedMs,
1734
1876
  durationMultiplier: 1,
1877
+ kittyManager: petKittyManager,
1735
1878
  },
1736
1879
  );
1737
1880
  petLines.push(...renderedPetLines);
@@ -1778,7 +1921,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1778
1921
 
1779
1922
  function clearFooter(ctx: ExtensionContext): void {
1780
1923
  if (!footerInstalled) return;
1781
- flushPetKittyCleanupNow();
1924
+ disposePetKittyNow();
1782
1925
  ctx.ui.setFooter(undefined);
1783
1926
  footerInstalled = false;
1784
1927
  requestFooterRender = undefined;
@@ -1885,7 +2028,6 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1885
2028
  pi.on("session_compact", (_event, ctx) => {
1886
2029
  refreshFooterTotals(ctx);
1887
2030
  queuePetKittyCleanup();
1888
- resetCodexPetKittyCache(pet, petImageId);
1889
2031
  resetPetRenderCache();
1890
2032
  updateFooter(ctx);
1891
2033
  });
@@ -1893,7 +2035,6 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1893
2035
  pi.on("session_tree", (_event, ctx) => {
1894
2036
  refreshFooterTotals(ctx);
1895
2037
  queuePetKittyCleanup();
1896
- resetCodexPetKittyCache(pet, petImageId);
1897
2038
  resetPetRenderCache();
1898
2039
  updateFooter(ctx);
1899
2040
  });
@@ -1927,8 +2068,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1927
2068
  usageTimer = undefined;
1928
2069
  activeToolCallIds.clear();
1929
2070
  uninstallPetResizeGuard();
1930
- queuePetKittyCleanup();
1931
- flushPetKittyCleanupNow();
2071
+ disposePetKittyNow();
1932
2072
  resetPetRenderCache();
1933
2073
  clearPetFlash();
1934
2074
  stopPetIdleEmotes();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-openai",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Personal pi extension that improves OpenAI with fast mode, usage stats, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
package/src/pets.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { Dirent } from "node:fs";
2
- import { access, readdir, readFile } from "node:fs/promises";
1
+ import { constants, type Dirent } from "node:fs";
2
+ import { access, readdir, readFile, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve, sep } from "node:path";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -21,6 +21,8 @@ const PET_COLUMNS = 8;
21
21
  const PET_ROWS = 9;
22
22
  const DEFAULT_CELL_WIDTH = 192;
23
23
  const DEFAULT_CELL_HEIGHT = 208;
24
+ const EXPECTED_ATLAS_WIDTH = DEFAULT_CELL_WIDTH * PET_COLUMNS;
25
+ const EXPECTED_ATLAS_HEIGHT = DEFAULT_CELL_HEIGHT * PET_ROWS;
24
26
 
25
27
  export const PET_ANIMATION_ROWS: Record<PetState, { row: number; durations: number[] }> = {
26
28
  idle: { row: 0, durations: [280, 110, 110, 140, 140, 320] },
@@ -42,6 +44,7 @@ export type CodexPetPackage = {
42
44
  dir: string;
43
45
  spritesheetPath: string;
44
46
  hasSpritesheet: boolean;
47
+ spritesheetIssue?: string;
45
48
  };
46
49
 
47
50
  export type PetFrame = {
@@ -70,6 +73,29 @@ type ThemeLike = {
70
73
  fg(color: string, value: string): string;
71
74
  };
72
75
 
76
+ const TERMINAL_ESCAPE_REGEXP = new RegExp(
77
+ String.raw`\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\)|[_PX^][\s\S]*?\u001b\\|\[[0-?]*[ -/]*[@-~]|[@-_][0-?]*[ -/]*[@-~])`,
78
+ "g",
79
+ );
80
+ const CONTROL_CHAR_REGEXP = new RegExp(String.raw`[\u0000-\u001f\u007f-\u009f]+`, "g");
81
+
82
+ function stripTerminalEscapes(value: string): string {
83
+ return value.replace(TERMINAL_ESCAPE_REGEXP, "");
84
+ }
85
+
86
+ function sanitizePetDisplayText(value: string): string | undefined {
87
+ const sanitized = stripTerminalEscapes(value)
88
+ .replace(CONTROL_CHAR_REGEXP, " ")
89
+ .replace(/\s+/g, " ")
90
+ .trim();
91
+ return sanitized || undefined;
92
+ }
93
+
94
+ function sanitizePetAssetPathText(value: string): string | undefined {
95
+ const sanitized = stripTerminalEscapes(value).replace(CONTROL_CHAR_REGEXP, "").trim();
96
+ return sanitized || undefined;
97
+ }
98
+
73
99
  export function codexHome(env = process.env, home = homedir()): string {
74
100
  return env.CODEX_HOME?.trim() || join(home, ".codex");
75
101
  }
@@ -82,24 +108,21 @@ function petInfoFromJson(
82
108
  value: unknown,
83
109
  fallback: string,
84
110
  ): { id?: string; name: string; description?: string; spritesheetPath: string } {
85
- if (!isRecord(value)) return { name: fallback, spritesheetPath: "spritesheet.webp" };
86
- const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : undefined;
111
+ const fallbackName = sanitizePetDisplayText(fallback) ?? "Unnamed pet";
112
+ if (!isRecord(value)) return { name: fallbackName, spritesheetPath: "spritesheet.webp" };
113
+ const id = typeof value.id === "string" ? sanitizePetDisplayText(value.id) : undefined;
87
114
  const displayName =
88
- typeof value.displayName === "string" && value.displayName.trim()
89
- ? value.displayName.trim()
90
- : undefined;
115
+ typeof value.displayName === "string" ? sanitizePetDisplayText(value.displayName) : undefined;
91
116
  const name =
92
117
  displayName ??
93
- (typeof value.name === "string" && value.name.trim() ? value.name.trim() : undefined) ??
118
+ (typeof value.name === "string" ? sanitizePetDisplayText(value.name) : undefined) ??
94
119
  id ??
95
- fallback;
120
+ fallbackName;
96
121
  const description =
97
- typeof value.description === "string" && value.description.trim()
98
- ? value.description.trim()
99
- : undefined;
122
+ typeof value.description === "string" ? sanitizePetDisplayText(value.description) : undefined;
100
123
  const spritesheetPath =
101
- typeof value.spritesheetPath === "string" && value.spritesheetPath.trim()
102
- ? value.spritesheetPath.trim()
124
+ typeof value.spritesheetPath === "string"
125
+ ? (sanitizePetAssetPathText(value.spritesheetPath) ?? "spritesheet.webp")
103
126
  : "spritesheet.webp";
104
127
  return { id, name, description, spritesheetPath };
105
128
  }
@@ -111,6 +134,48 @@ function resolvePetAssetPath(petDir: string, path: string): string | undefined {
111
134
  return resolved === resolvedPetDir || resolved.startsWith(prefix) ? resolved : undefined;
112
135
  }
113
136
 
137
+ function formatSharpError(error: unknown): string {
138
+ return (
139
+ sanitizePetDisplayText(error instanceof Error ? error.message : String(error)) ??
140
+ "unknown error"
141
+ );
142
+ }
143
+
144
+ async function validatePetSpritesheet(
145
+ petDir: string,
146
+ spritesheetPath: string,
147
+ ): Promise<string | undefined> {
148
+ const resolvedSpritesheetPath = resolvePetAssetPath(petDir, spritesheetPath);
149
+ if (!resolvedSpritesheetPath)
150
+ return `invalid spritesheetPath outside pet folder: ${spritesheetPath}`;
151
+
152
+ let fileStat;
153
+ try {
154
+ fileStat = await stat(resolvedSpritesheetPath);
155
+ } catch {
156
+ return `missing ${spritesheetPath}`;
157
+ }
158
+
159
+ if (!fileStat.isFile()) return `${spritesheetPath} is not a file`;
160
+
161
+ try {
162
+ await access(resolvedSpritesheetPath, constants.R_OK);
163
+ } catch {
164
+ return `${spritesheetPath} is not readable`;
165
+ }
166
+
167
+ try {
168
+ const metadata = await sharp(resolvedSpritesheetPath, { animated: false }).metadata();
169
+ if (metadata.width !== EXPECTED_ATLAS_WIDTH || metadata.height !== EXPECTED_ATLAS_HEIGHT) {
170
+ return `invalid atlas dimensions: ${metadata.width ?? "?"}x${metadata.height ?? "?"}; expected ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT}`;
171
+ }
172
+ } catch (error) {
173
+ return `could not read ${spritesheetPath}: ${formatSharpError(error)}`;
174
+ }
175
+
176
+ return undefined;
177
+ }
178
+
114
179
  export async function listCodexPets(home = codexHome()): Promise<CodexPetPackage[]> {
115
180
  const dir = codexPetsDir(home);
116
181
  let entries: Dirent[];
@@ -131,16 +196,7 @@ export async function listCodexPets(home = codexHome()): Promise<CodexPetPackage
131
196
  continue;
132
197
  }
133
198
  const { id, name, description, spritesheetPath } = petInfoFromJson(parsed, entry.name);
134
- let hasSpritesheet = false;
135
- try {
136
- const resolvedSpritesheetPath = resolvePetAssetPath(petDir, spritesheetPath);
137
- if (resolvedSpritesheetPath) {
138
- await access(resolvedSpritesheetPath);
139
- hasSpritesheet = true;
140
- }
141
- } catch {
142
- hasSpritesheet = false;
143
- }
199
+ const spritesheetIssue = await validatePetSpritesheet(petDir, spritesheetPath);
144
200
  pets.push({
145
201
  slug: entry.name,
146
202
  id,
@@ -148,7 +204,8 @@ export async function listCodexPets(home = codexHome()): Promise<CodexPetPackage
148
204
  description,
149
205
  dir: petDir,
150
206
  spritesheetPath,
151
- hasSpritesheet,
207
+ hasSpritesheet: spritesheetIssue === undefined,
208
+ spritesheetIssue,
152
209
  });
153
210
  }
154
211
  return pets.sort((a, b) => a.name.localeCompare(b.name));
@@ -222,7 +279,7 @@ export function describeCodexPetSelectionIssue(
222
279
  short: `Pet "${matchingPet.name}" is not ready.`,
223
280
  message: [
224
281
  `Pet "${matchingPet.name}" (${matchingPet.slug}) exists but is not ready.`,
225
- `Missing or unreadable file: ${matchingPet.spritesheetPath}`,
282
+ `Problem: ${matchingPet.spritesheetIssue ?? `missing ${matchingPet.spritesheetPath}`}`,
226
283
  "",
227
284
  "Run /pets list for diagnostics, then fix the pet folder:",
228
285
  ` ${matchingPet.dir}/`,
@@ -266,7 +323,7 @@ export function formatNoReadyCodexPetsMessage(pets: CodexPetPackage[], home = co
266
323
  return [
267
324
  "Found custom Codex pets, but none are ready.",
268
325
  "",
269
- "A ready pet needs both pet.json and spritesheet.webp in its pet folder.",
326
+ `A ready pet needs pet.json and a readable ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT} spritesheet.webp atlas in its pet folder.`,
270
327
  "Run /pets list to see what needs fixing, or create a new pet:",
271
328
  "",
272
329
  formatCodexPetSetupInstructions(home),
@@ -292,12 +349,9 @@ export async function loadCodexPet(
292
349
  throw new Error(`Invalid spritesheetPath outside pet folder: ${pet.spritesheetPath}`);
293
350
  const source = sharp(spritesheetPath, { animated: false });
294
351
  const metadata = await source.metadata();
295
- if (
296
- metadata.width !== DEFAULT_CELL_WIDTH * PET_COLUMNS ||
297
- metadata.height !== DEFAULT_CELL_HEIGHT * PET_ROWS
298
- ) {
352
+ if (metadata.width !== EXPECTED_ATLAS_WIDTH || metadata.height !== EXPECTED_ATLAS_HEIGHT) {
299
353
  throw new Error(
300
- `Invalid Codex pet atlas dimensions: ${metadata.width ?? "?"}x${metadata.height ?? "?"}; expected ${DEFAULT_CELL_WIDTH * PET_COLUMNS}x${DEFAULT_CELL_HEIGHT * PET_ROWS}.`,
354
+ `Invalid Codex pet atlas dimensions: ${metadata.width ?? "?"}x${metadata.height ?? "?"}; expected ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT}.`,
301
355
  );
302
356
  }
303
357
  const cellWidth = DEFAULT_CELL_WIDTH;
@@ -401,16 +455,24 @@ export function nextAnimationFrameDelayMs(
401
455
  return Math.max(1, Math.ceil(frames[0]?.durationMs ?? 120));
402
456
  }
403
457
 
404
- const previousKittyFrameByPlacement = new Map<number, number>();
405
-
406
- export function resetCodexPetKittyCache(pet?: LoadedCodexPet, placementImageId?: number): void {
407
- if (placementImageId !== undefined) previousKittyFrameByPlacement.delete(placementImageId);
458
+ function markCodexPetKittyFramesUnuploaded(pet?: LoadedCodexPet): void {
408
459
  if (!pet) return;
409
460
  for (const frames of Object.values(pet.states)) {
410
461
  for (const frame of frames) frame.kittyUploaded = false;
411
462
  }
412
463
  }
413
464
 
465
+ function codexPetKittyImageIds(pet?: LoadedCodexPet): number[] {
466
+ if (!pet) return [];
467
+ const imageIds = new Set<number>();
468
+ for (const frames of Object.values(pet.states)) {
469
+ for (const frame of frames) {
470
+ if (frame.kittyImageId !== undefined) imageIds.add(frame.kittyImageId);
471
+ }
472
+ }
473
+ return Array.from(imageIds);
474
+ }
475
+
414
476
  export function deleteCodexPetKittyPlacement(imageId: number): string {
415
477
  return `\x1b_Ga=d,d=i,i=${imageId},p=1,q=2\x1b\\`;
416
478
  }
@@ -450,39 +512,95 @@ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
450
512
  return chunks.join("");
451
513
  }
452
514
 
453
- function renderKittyPetFrame(
454
- frame: PetFrame,
455
- width: number,
456
- options: { sizeCells: number; imageId: number },
457
- ): string[] {
458
- const columns = Math.max(1, Math.min(Math.max(1, width - 2), options.sizeCells));
459
- const rows = calculateImageRows(
460
- { widthPx: frame.widthPx, heightPx: frame.heightPx },
461
- columns,
462
- getCellDimensions(),
463
- );
464
- const frameImageId = frame.kittyImageId ?? options.imageId;
465
- const previousFrameImageId = previousKittyFrameByPlacement.get(options.imageId);
466
- const deletePrevious =
467
- previousFrameImageId !== undefined && previousFrameImageId !== frameImageId
468
- ? deleteCodexPetKittyPlacement(previousFrameImageId)
469
- : "";
470
- const deleteCurrent = deleteCodexPetKittyPlacement(frameImageId);
471
- const upload = frame.kittyUploaded ? "" : encodeKittyRawRgba(frame, frameImageId);
472
- frame.kittyUploaded = true;
473
- previousKittyFrameByPlacement.set(options.imageId, frameImageId);
474
- // Recent pi-tui versions automatically free Kitty image IDs they own on changed lines.
475
- // Pet frames are managed here and intentionally reused across animation loops, so keep
476
- // the first Kitty command pointed at a harmless footer-placement ID, not a frame ID.
477
- const hostCleanupSentinel =
478
- frameImageId !== options.imageId ? deleteCodexPetKittyPlacement(options.imageId) : "";
479
- const sequence = `${hostCleanupSentinel}${deletePrevious}${deleteCurrent}${upload}${placeKittyImage(frameImageId, columns, rows)}`;
480
- const lines: string[] = [];
481
- for (let i = 0; i < rows - 1; i++) lines.push("");
482
- const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
483
- const moveDown = rows > 1 ? `\x1b[${rows - 1}B` : "";
484
- lines.push(moveUp + sequence + moveDown);
485
- return lines;
515
+ export class CodexPetKittyManager {
516
+ private previousFrameImageId: number | undefined;
517
+ private readonly uploadedFrameImageIds = new Set<number>();
518
+ private readonly pendingCleanupImageIds = new Set<number>();
519
+
520
+ constructor(readonly placementImageId: number) {}
521
+
522
+ queueCleanup(pet?: LoadedCodexPet): void {
523
+ for (const imageId of codexPetKittyImageIds(pet)) this.pendingCleanupImageIds.add(imageId);
524
+ }
525
+
526
+ invalidate(pet?: LoadedCodexPet): void {
527
+ this.queueCleanup(pet);
528
+ this.previousFrameImageId = undefined;
529
+ this.uploadedFrameImageIds.clear();
530
+ markCodexPetKittyFramesUnuploaded(pet);
531
+ }
532
+
533
+ resetForResize(pet?: LoadedCodexPet): void {
534
+ this.invalidate(pet);
535
+ }
536
+
537
+ dispose(pet?: LoadedCodexPet): string {
538
+ this.invalidate(pet);
539
+ return this.takeCleanupSequence();
540
+ }
541
+
542
+ takeCleanupSequence(): string {
543
+ if (this.pendingCleanupImageIds.size === 0) return "";
544
+ const sequence = `${deleteCodexPetKittyPlacement(this.placementImageId)}${Array.from(
545
+ this.pendingCleanupImageIds,
546
+ )
547
+ .map((imageId) => deleteCodexPetKittyImage(imageId))
548
+ .join("")}`;
549
+ this.pendingCleanupImageIds.clear();
550
+ return sequence;
551
+ }
552
+
553
+ renderFrame(frame: PetFrame, width: number, options: { sizeCells: number }): string[] {
554
+ const columns = Math.max(1, Math.min(Math.max(1, width - 2), options.sizeCells));
555
+ const rows = calculateImageRows(
556
+ { widthPx: frame.widthPx, heightPx: frame.heightPx },
557
+ columns,
558
+ getCellDimensions(),
559
+ );
560
+ const frameImageId = frame.kittyImageId ?? this.placementImageId;
561
+ const deletePrevious =
562
+ this.previousFrameImageId !== undefined && this.previousFrameImageId !== frameImageId
563
+ ? deleteCodexPetKittyPlacement(this.previousFrameImageId)
564
+ : "";
565
+ const deleteCurrent = deleteCodexPetKittyPlacement(frameImageId);
566
+ const upload = this.uploadedFrameImageIds.has(frameImageId)
567
+ ? ""
568
+ : encodeKittyRawRgba(frame, frameImageId);
569
+ this.uploadedFrameImageIds.add(frameImageId);
570
+ frame.kittyUploaded = true;
571
+ this.previousFrameImageId = frameImageId;
572
+ // Recent pi-tui versions automatically free Kitty image IDs they own on changed lines.
573
+ // Pet frames are managed here and intentionally reused across animation loops, so keep
574
+ // the first Kitty command pointed at a harmless footer-placement ID, not a frame ID.
575
+ const hostCleanupSentinel =
576
+ frameImageId !== this.placementImageId
577
+ ? deleteCodexPetKittyPlacement(this.placementImageId)
578
+ : "";
579
+ const sequence = `${hostCleanupSentinel}${deletePrevious}${deleteCurrent}${upload}${placeKittyImage(frameImageId, columns, rows)}`;
580
+ const lines: string[] = [];
581
+ for (let i = 0; i < rows - 1; i++) lines.push("");
582
+ const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
583
+ const moveDown = rows > 1 ? `\x1b[${rows - 1}B` : "";
584
+ lines.push(moveUp + sequence + moveDown);
585
+ return lines;
586
+ }
587
+ }
588
+
589
+ const defaultKittyManagers = new Map<number, CodexPetKittyManager>();
590
+
591
+ function defaultKittyManager(placementImageId: number): CodexPetKittyManager {
592
+ let manager = defaultKittyManagers.get(placementImageId);
593
+ if (!manager) {
594
+ manager = new CodexPetKittyManager(placementImageId);
595
+ defaultKittyManagers.set(placementImageId, manager);
596
+ }
597
+ return manager;
598
+ }
599
+
600
+ export function resetCodexPetKittyCache(pet?: LoadedCodexPet, placementImageId?: number): void {
601
+ if (placementImageId !== undefined) defaultKittyManagers.get(placementImageId)?.invalidate(pet);
602
+ else for (const manager of defaultKittyManagers.values()) manager.invalidate(pet);
603
+ markCodexPetKittyFramesUnuploaded(pet);
486
604
  }
487
605
 
488
606
  export function renderCodexPetFrame(
@@ -490,7 +608,13 @@ export function renderCodexPetFrame(
490
608
  state: PetState,
491
609
  width: number,
492
610
  theme: ThemeLike,
493
- options: { sizeCells: number; imageId: number; now?: number; durationMultiplier?: number },
611
+ options: {
612
+ sizeCells: number;
613
+ imageId: number;
614
+ now?: number;
615
+ durationMultiplier?: number;
616
+ kittyManager?: CodexPetKittyManager;
617
+ },
494
618
  ): string[] {
495
619
  const frame = animationFrameAt(
496
620
  pet.states[state] ?? pet.states.idle,
@@ -499,7 +623,15 @@ export function renderCodexPetFrame(
499
623
  );
500
624
  if (!frame) return [];
501
625
  const imageProtocol = getCapabilities().images;
502
- if (imageProtocol === "kitty") return renderKittyPetFrame(frame, width, options);
626
+ if (imageProtocol === "kitty") {
627
+ return (options.kittyManager ?? defaultKittyManager(options.imageId)).renderFrame(
628
+ frame,
629
+ width,
630
+ {
631
+ sizeCells: options.sizeCells,
632
+ },
633
+ );
634
+ }
503
635
  if (!imageProtocol) {
504
636
  const fallback = imageFallback(frame.mimeType, {
505
637
  widthPx: frame.widthPx,
@@ -549,7 +681,7 @@ const PET_SUBCOMMANDS: AutocompleteItem[] = [
549
681
  ];
550
682
 
551
683
  function petCompletionValue(subcommand: string, pet: CodexPetPackage): string {
552
- return `${subcommand} ${pet.name}`;
684
+ return `${subcommand} ${pet.slug}`;
553
685
  }
554
686
 
555
687
  export async function openAIPetsArgumentCompletions(
@@ -594,7 +726,9 @@ export function formatCodexPetsListMessage(pets: CodexPetPackage[], home = codex
594
726
 
595
727
  const petLines = pets
596
728
  .map((pet) => {
597
- const status = pet.hasSpritesheet ? "ready" : `missing ${pet.spritesheetPath}`;
729
+ const status = pet.hasSpritesheet
730
+ ? "ready"
731
+ : (pet.spritesheetIssue ?? `missing ${pet.spritesheetPath}`);
598
732
  return `${pet.name} (${pet.slug}) — ${status}${pet.description ? `\n ${pet.description}` : ""}`;
599
733
  })
600
734
  .join("\n");