@monotykamary/pi-better-openai 0.1.23
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/LICENSE +21 -0
- package/README.md +191 -0
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/index.ts +1328 -0
- package/package.json +94 -0
- package/src/codex-auth.ts +136 -0
- package/src/config.ts +725 -0
- package/src/fast-controller.ts +112 -0
- package/src/footer-layout.ts +97 -0
- package/src/format.ts +104 -0
- package/src/identity.ts +7 -0
- package/src/image.ts +672 -0
- package/src/live/attestation.ts +86 -0
- package/src/live/controller.ts +523 -0
- package/src/live/index.ts +197 -0
- package/src/live/native.ts +109 -0
- package/src/live/protocol.ts +234 -0
- package/src/live/transport.ts +445 -0
- package/src/live/visualizer.ts +269 -0
- package/src/live/voices.ts +21 -0
- package/src/paths.ts +17 -0
- package/src/pet-footer-controller.ts +642 -0
- package/src/pets.ts +983 -0
- package/src/usage-controller.ts +304 -0
- package/src/usage.ts +264 -0
package/src/pets.ts
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
import { constants, type Dirent } from "node:fs";
|
|
2
|
+
import { access, lstat, readdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
calculateImageRows,
|
|
8
|
+
getCapabilities,
|
|
9
|
+
getCellDimensions,
|
|
10
|
+
Image,
|
|
11
|
+
imageFallback,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
type AutocompleteItem,
|
|
14
|
+
type ImageTheme,
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
16
|
+
import sharp from "sharp";
|
|
17
|
+
import { isRecord, type PetState } from "./config.ts";
|
|
18
|
+
|
|
19
|
+
const PETS_COMMAND = "pets";
|
|
20
|
+
const PET_COLUMNS = 8;
|
|
21
|
+
const PET_ROWS = 9;
|
|
22
|
+
const DEFAULT_CELL_WIDTH = 192;
|
|
23
|
+
const DEFAULT_CELL_HEIGHT = 208;
|
|
24
|
+
const DEFAULT_SPRITESHEET_PATH = "spritesheet.webp";
|
|
25
|
+
const EXPECTED_ATLAS_WIDTH = DEFAULT_CELL_WIDTH * PET_COLUMNS;
|
|
26
|
+
const EXPECTED_ATLAS_HEIGHT = DEFAULT_CELL_HEIGHT * PET_ROWS;
|
|
27
|
+
const PET_CATALOG_CACHE_TTL_MS = 1500;
|
|
28
|
+
const PET_CATALOG_READ_CONCURRENCY = 4;
|
|
29
|
+
|
|
30
|
+
type ListCodexPetsOptions = { refresh?: boolean };
|
|
31
|
+
|
|
32
|
+
type PetCatalogCacheEntry = {
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
pets: CodexPetPackage[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const petCatalogCache = new Map<string, PetCatalogCacheEntry>();
|
|
38
|
+
|
|
39
|
+
export const PET_ANIMATION_ROWS: Record<PetState, { row: number; durations: number[] }> = {
|
|
40
|
+
idle: { row: 0, durations: [280, 110, 110, 140, 140, 320] },
|
|
41
|
+
"running-right": { row: 1, durations: [120, 120, 120, 120, 120, 120, 120, 220] },
|
|
42
|
+
"running-left": { row: 2, durations: [120, 120, 120, 120, 120, 120, 120, 220] },
|
|
43
|
+
waving: { row: 3, durations: [140, 140, 140, 280] },
|
|
44
|
+
jumping: { row: 4, durations: [140, 140, 140, 140, 280] },
|
|
45
|
+
failed: { row: 5, durations: [140, 140, 140, 140, 140, 140, 140, 240] },
|
|
46
|
+
waiting: { row: 6, durations: [150, 150, 150, 150, 150, 260] },
|
|
47
|
+
running: { row: 7, durations: [120, 120, 120, 120, 120, 220] },
|
|
48
|
+
review: { row: 8, durations: [150, 150, 150, 150, 150, 280] },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type CodexPetPackage = {
|
|
52
|
+
slug: string;
|
|
53
|
+
id?: string;
|
|
54
|
+
name: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
dir: string;
|
|
57
|
+
spritesheetPath: string;
|
|
58
|
+
hasSpritesheet: boolean;
|
|
59
|
+
spritesheetIssue?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type PetFrame = {
|
|
63
|
+
data?: string;
|
|
64
|
+
rawRgbaData?: string;
|
|
65
|
+
kittyUpload?: string;
|
|
66
|
+
kittyImageId?: number;
|
|
67
|
+
mimeType: "image/png";
|
|
68
|
+
durationMs: number;
|
|
69
|
+
widthPx: number;
|
|
70
|
+
heightPx: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type LoadedCodexPet = {
|
|
74
|
+
pet: CodexPetPackage;
|
|
75
|
+
states: Record<PetState, PetFrame[]>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type PetsCommandController = {
|
|
79
|
+
wake?: (ctx: ExtensionContext, slug?: string) => Promise<void> | void;
|
|
80
|
+
tuck?: (ctx: ExtensionContext) => Promise<void> | void;
|
|
81
|
+
select?: (ctx: ExtensionContext, slug?: string) => Promise<void> | void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type ThemeLike = {
|
|
85
|
+
fg(color: string, value: string): string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const TERMINAL_ESCAPE_REGEXP = new RegExp(
|
|
89
|
+
String.raw`\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\)|[_PX^][\s\S]*?\u001b\\|\[[0-?]*[ -/]*[@-~]|[@-_][0-?]*[ -/]*[@-~])`,
|
|
90
|
+
"g",
|
|
91
|
+
);
|
|
92
|
+
const CONTROL_CHAR_REGEXP = new RegExp(String.raw`[\u0000-\u001f\u007f-\u009f]+`, "g");
|
|
93
|
+
|
|
94
|
+
function stripTerminalEscapes(value: string): string {
|
|
95
|
+
return value.replace(TERMINAL_ESCAPE_REGEXP, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizePetDisplayText(value: string): string | undefined {
|
|
99
|
+
const sanitized = stripTerminalEscapes(value)
|
|
100
|
+
.replace(CONTROL_CHAR_REGEXP, " ")
|
|
101
|
+
.replace(/\s+/g, " ")
|
|
102
|
+
.trim();
|
|
103
|
+
return sanitized || undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sanitizePetAssetPathText(value: string): string | undefined {
|
|
107
|
+
const sanitized = stripTerminalEscapes(value).replace(CONTROL_CHAR_REGEXP, "").trim();
|
|
108
|
+
return sanitized || undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sanitizePetDisplayField(value: unknown): string | undefined {
|
|
112
|
+
return typeof value === "string" ? sanitizePetDisplayText(value) : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sanitizePetAssetPathField(value: unknown): string | undefined {
|
|
116
|
+
return typeof value === "string" ? sanitizePetAssetPathText(value) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sanitizePetSlug(value: string): string {
|
|
120
|
+
return sanitizePetDisplayText(value) ?? "unnamed-pet";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function codexHome(env = process.env, home = homedir()): string {
|
|
124
|
+
return env.CODEX_HOME?.trim() || join(home, ".codex");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function codexPetsDir(home = codexHome()): string {
|
|
128
|
+
return join(home, "pets");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function petInfoFromJson(
|
|
132
|
+
value: unknown,
|
|
133
|
+
fallback: string,
|
|
134
|
+
): { id?: string; name: string; description?: string; spritesheetPath: string } {
|
|
135
|
+
const fallbackName = sanitizePetDisplayText(fallback) ?? "Unnamed pet";
|
|
136
|
+
if (!isRecord(value)) return { name: fallbackName, spritesheetPath: DEFAULT_SPRITESHEET_PATH };
|
|
137
|
+
|
|
138
|
+
const id = sanitizePetDisplayField(value.id);
|
|
139
|
+
const name =
|
|
140
|
+
sanitizePetDisplayField(value.displayName) ??
|
|
141
|
+
sanitizePetDisplayField(value.name) ??
|
|
142
|
+
id ??
|
|
143
|
+
fallbackName;
|
|
144
|
+
const description = sanitizePetDisplayField(value.description);
|
|
145
|
+
const spritesheetPath =
|
|
146
|
+
sanitizePetAssetPathField(value.spritesheetPath) ?? DEFAULT_SPRITESHEET_PATH;
|
|
147
|
+
return { id, name, description, spritesheetPath };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isPathInsideDirectory(parent: string, child: string): boolean {
|
|
151
|
+
const resolvedParent = resolve(parent);
|
|
152
|
+
const resolvedChild = resolve(child);
|
|
153
|
+
const prefix = resolvedParent.endsWith(sep) ? resolvedParent : `${resolvedParent}${sep}`;
|
|
154
|
+
return resolvedChild === resolvedParent || resolvedChild.startsWith(prefix);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolvePetAssetPath(petDir: string, path: string): string | undefined {
|
|
158
|
+
const resolvedPetDir = resolve(petDir);
|
|
159
|
+
const resolved = resolve(resolvedPetDir, path);
|
|
160
|
+
return isPathInsideDirectory(resolvedPetDir, resolved) ? resolved : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatSharpError(error: unknown): string {
|
|
164
|
+
return (
|
|
165
|
+
sanitizePetDisplayText(error instanceof Error ? error.message : String(error)) ??
|
|
166
|
+
"unknown error"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function validatePetSpritesheet(
|
|
171
|
+
petDir: string,
|
|
172
|
+
spritesheetPath: string,
|
|
173
|
+
): Promise<string | undefined> {
|
|
174
|
+
const resolvedSpritesheetPath = resolvePetAssetPath(petDir, spritesheetPath);
|
|
175
|
+
if (!resolvedSpritesheetPath)
|
|
176
|
+
return `invalid spritesheetPath outside pet folder: ${spritesheetPath}`;
|
|
177
|
+
|
|
178
|
+
let fileStat;
|
|
179
|
+
try {
|
|
180
|
+
fileStat = await lstat(resolvedSpritesheetPath);
|
|
181
|
+
} catch {
|
|
182
|
+
return `missing ${spritesheetPath}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (fileStat.isSymbolicLink()) return `${spritesheetPath} must not be a symlink`;
|
|
186
|
+
if (!fileStat.isFile()) return `${spritesheetPath} is not a file`;
|
|
187
|
+
|
|
188
|
+
const realPetDir = await realpath(petDir).catch(() => undefined);
|
|
189
|
+
const realSpritesheetPath = await realpath(resolvedSpritesheetPath).catch(() => undefined);
|
|
190
|
+
if (
|
|
191
|
+
!realPetDir ||
|
|
192
|
+
!realSpritesheetPath ||
|
|
193
|
+
!isPathInsideDirectory(realPetDir, realSpritesheetPath)
|
|
194
|
+
)
|
|
195
|
+
return `invalid spritesheetPath outside pet folder: ${spritesheetPath}`;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await access(resolvedSpritesheetPath, constants.R_OK);
|
|
199
|
+
} catch {
|
|
200
|
+
return `${spritesheetPath} is not readable`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const metadata = await sharp(resolvedSpritesheetPath, { animated: false }).metadata();
|
|
205
|
+
if (metadata.width !== EXPECTED_ATLAS_WIDTH || metadata.height !== EXPECTED_ATLAS_HEIGHT) {
|
|
206
|
+
return `invalid atlas dimensions: ${metadata.width ?? "?"}x${metadata.height ?? "?"}; expected ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT}`;
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return `could not read ${spritesheetPath}: ${formatSharpError(error)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function clearPetCatalogCache(home?: string): void {
|
|
216
|
+
if (home === undefined) {
|
|
217
|
+
petCatalogCache.clear();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
petCatalogCache.delete(resolve(home));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function readPetDirectoryEntries(home: string): Promise<{ dir: string; entries: Dirent[] }> {
|
|
224
|
+
const dir = codexPetsDir(home);
|
|
225
|
+
try {
|
|
226
|
+
return { dir, entries: await readdir(dir, { withFileTypes: true }) };
|
|
227
|
+
} catch {
|
|
228
|
+
return { dir, entries: [] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readCodexPetPackage(
|
|
233
|
+
petsDir: string,
|
|
234
|
+
entry: Dirent,
|
|
235
|
+
options: { validateSpritesheet: boolean },
|
|
236
|
+
): Promise<CodexPetPackage | undefined> {
|
|
237
|
+
if (!entry.isDirectory()) return undefined;
|
|
238
|
+
const petDir = join(petsDir, entry.name);
|
|
239
|
+
const slug = sanitizePetSlug(entry.name);
|
|
240
|
+
let parsed: unknown;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(await readFile(join(petDir, "pet.json"), "utf8")) as unknown;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return {
|
|
245
|
+
slug,
|
|
246
|
+
name: sanitizePetDisplayText(entry.name) ?? "Unnamed pet",
|
|
247
|
+
dir: petDir,
|
|
248
|
+
spritesheetPath: DEFAULT_SPRITESHEET_PATH,
|
|
249
|
+
hasSpritesheet: false,
|
|
250
|
+
spritesheetIssue: `invalid pet.json: ${formatSharpError(error)}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const { id, name, description, spritesheetPath } = petInfoFromJson(parsed, entry.name);
|
|
254
|
+
const spritesheetIssue = options.validateSpritesheet
|
|
255
|
+
? await validatePetSpritesheet(petDir, spritesheetPath)
|
|
256
|
+
: undefined;
|
|
257
|
+
return {
|
|
258
|
+
slug,
|
|
259
|
+
id,
|
|
260
|
+
name,
|
|
261
|
+
description,
|
|
262
|
+
dir: petDir,
|
|
263
|
+
spritesheetPath,
|
|
264
|
+
hasSpritesheet: options.validateSpritesheet && spritesheetIssue === undefined,
|
|
265
|
+
spritesheetIssue,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readCodexPetPackages(
|
|
270
|
+
petsDir: string,
|
|
271
|
+
entries: Dirent[],
|
|
272
|
+
options: { validateSpritesheet: boolean },
|
|
273
|
+
): Promise<CodexPetPackage[]> {
|
|
274
|
+
const pets: Array<CodexPetPackage | undefined> = Array.from({ length: entries.length });
|
|
275
|
+
let nextIndex = 0;
|
|
276
|
+
const readNext = async (): Promise<void> => {
|
|
277
|
+
while (nextIndex < entries.length) {
|
|
278
|
+
const index = nextIndex++;
|
|
279
|
+
const entry = entries[index];
|
|
280
|
+
if (entry) pets[index] = await readCodexPetPackage(petsDir, entry, options);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
await Promise.all(
|
|
284
|
+
Array.from({ length: Math.min(PET_CATALOG_READ_CONCURRENCY, entries.length) }, readNext),
|
|
285
|
+
);
|
|
286
|
+
return pets.filter((pet): pet is CodexPetPackage => pet !== undefined);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function listCodexPets(
|
|
290
|
+
home = codexHome(),
|
|
291
|
+
options: ListCodexPetsOptions = {},
|
|
292
|
+
): Promise<CodexPetPackage[]> {
|
|
293
|
+
const cacheKey = resolve(home);
|
|
294
|
+
const cached = petCatalogCache.get(cacheKey);
|
|
295
|
+
if (!options.refresh && cached && cached.expiresAt > Date.now()) return cached.pets;
|
|
296
|
+
|
|
297
|
+
const { dir, entries } = await readPetDirectoryEntries(home);
|
|
298
|
+
const pets = await readCodexPetPackages(dir, entries, { validateSpritesheet: true });
|
|
299
|
+
pets.sort((a, b) => a.name.localeCompare(b.name));
|
|
300
|
+
petCatalogCache.set(cacheKey, { expiresAt: Date.now() + PET_CATALOG_CACHE_TTL_MS, pets });
|
|
301
|
+
return pets;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function petLookupKey(value: string): string {
|
|
305
|
+
return value
|
|
306
|
+
.normalize("NFKC")
|
|
307
|
+
.toLowerCase()
|
|
308
|
+
.replace(/[^\p{L}\p{N}\p{M}]+/gu, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type PetLookupRequest = {
|
|
312
|
+
hasText: boolean;
|
|
313
|
+
exact?: string;
|
|
314
|
+
key?: string;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
function petLookupRequest(value?: string): PetLookupRequest {
|
|
318
|
+
const requestedText = value?.trim();
|
|
319
|
+
if (!requestedText) return { hasText: false };
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
hasText: true,
|
|
323
|
+
exact: requestedText.normalize("NFKC").toLowerCase(),
|
|
324
|
+
key: petLookupKey(requestedText) || undefined,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function petMatchesLookup(pet: CodexPetPackage, requested: string): boolean {
|
|
329
|
+
return (
|
|
330
|
+
petLookupKey(pet.slug) === requested ||
|
|
331
|
+
(pet.id !== undefined && petLookupKey(pet.id) === requested) ||
|
|
332
|
+
petLookupKey(pet.name) === requested
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function exactPetMatches(
|
|
337
|
+
pets: CodexPetPackage[],
|
|
338
|
+
requested: string,
|
|
339
|
+
): CodexPetPackage[] | undefined {
|
|
340
|
+
const fields: Array<(pet: CodexPetPackage) => string | undefined> = [
|
|
341
|
+
(pet) => pet.slug,
|
|
342
|
+
(pet) => pet.id,
|
|
343
|
+
(pet) => pet.name,
|
|
344
|
+
];
|
|
345
|
+
for (const field of fields) {
|
|
346
|
+
const matches = pets.filter((pet) => field(pet)?.normalize("NFKC").toLowerCase() === requested);
|
|
347
|
+
if (matches.length > 0) return matches;
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function uniquePetMatch(pets: CodexPetPackage[]): CodexPetPackage | undefined {
|
|
353
|
+
return pets.length === 1 ? pets[0] : undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function findCodexPet(pets: CodexPetPackage[], value?: string): CodexPetPackage | undefined {
|
|
357
|
+
const request = petLookupRequest(value);
|
|
358
|
+
const { key } = request;
|
|
359
|
+
if (!key) return undefined;
|
|
360
|
+
if (request.exact) {
|
|
361
|
+
const exactMatches = exactPetMatches(pets, request.exact);
|
|
362
|
+
if (exactMatches) return uniquePetMatch(exactMatches);
|
|
363
|
+
}
|
|
364
|
+
return uniquePetMatch(pets.filter((pet) => petMatchesLookup(pet, key)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function findReadyCodexPet(
|
|
368
|
+
pets: CodexPetPackage[],
|
|
369
|
+
value?: string,
|
|
370
|
+
): CodexPetPackage | undefined {
|
|
371
|
+
const request = petLookupRequest(value);
|
|
372
|
+
if (!request.hasText) return pets.find((pet) => pet.hasSpritesheet);
|
|
373
|
+
|
|
374
|
+
const { key } = request;
|
|
375
|
+
if (!key) return undefined;
|
|
376
|
+
if (request.exact) {
|
|
377
|
+
const exactMatches = exactPetMatches(pets, request.exact);
|
|
378
|
+
if (exactMatches) {
|
|
379
|
+
const exact = uniquePetMatch(exactMatches);
|
|
380
|
+
return exact?.hasSpritesheet ? exact : undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return uniquePetMatch(pets.filter((pet) => pet.hasSpritesheet && petMatchesLookup(pet, key)));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function selectPet(pets: CodexPetPackage[], slug?: string): CodexPetPackage | undefined {
|
|
387
|
+
return findReadyCodexPet(pets, slug);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export type CodexPetSelectionIssue = {
|
|
391
|
+
short: string;
|
|
392
|
+
message: string;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export function formatCodexPetSetupInstructions(home = codexHome()): string {
|
|
396
|
+
return [
|
|
397
|
+
"Expected folder:",
|
|
398
|
+
` ${codexPetsDir(home)}/<pet-name>/`,
|
|
399
|
+
"",
|
|
400
|
+
"Expected files:",
|
|
401
|
+
" pet.json",
|
|
402
|
+
` ${DEFAULT_SPRITESHEET_PATH}`,
|
|
403
|
+
"",
|
|
404
|
+
"Create one:",
|
|
405
|
+
" $skill-installer hatch-pet",
|
|
406
|
+
" Cmd/Ctrl+K → Force Reload Skills",
|
|
407
|
+
" $hatch-pet create a new pet inspired by pi-better-openai",
|
|
408
|
+
].join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function describeCodexPetSelectionIssue(
|
|
412
|
+
pets: CodexPetPackage[],
|
|
413
|
+
slug?: string,
|
|
414
|
+
home = codexHome(),
|
|
415
|
+
): CodexPetSelectionIssue {
|
|
416
|
+
const requestedSlug = slug?.trim();
|
|
417
|
+
const readyPets = pets.filter((pet) => pet.hasSpritesheet);
|
|
418
|
+
|
|
419
|
+
if (requestedSlug) {
|
|
420
|
+
const matchingPet = findCodexPet(pets, requestedSlug);
|
|
421
|
+
if (matchingPet && !matchingPet.hasSpritesheet) {
|
|
422
|
+
return {
|
|
423
|
+
short: `Pet "${matchingPet.name}" is not ready.`,
|
|
424
|
+
message: [
|
|
425
|
+
`Pet "${matchingPet.name}" (${matchingPet.slug}) exists but is not ready.`,
|
|
426
|
+
`Problem: ${matchingPet.spritesheetIssue ?? `missing ${matchingPet.spritesheetPath}`}`,
|
|
427
|
+
"",
|
|
428
|
+
"Run /pets list for diagnostics, then fix the pet folder:",
|
|
429
|
+
` ${matchingPet.dir}/`,
|
|
430
|
+
].join("\n"),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const readyList = readyPets.length
|
|
435
|
+
? ["Ready pets:", ...readyPets.map((pet) => ` - ${pet.slug} (${pet.name})`)].join("\n")
|
|
436
|
+
: formatNoReadyCodexPetsMessage(pets, home);
|
|
437
|
+
return {
|
|
438
|
+
short: `No ready pet matching "${requestedSlug}".`,
|
|
439
|
+
message: [`No ready pet matching "${requestedSlug}".`, "", readyList].join("\n"),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (pets.length === 0) {
|
|
444
|
+
return {
|
|
445
|
+
short: "No custom Codex pets found.",
|
|
446
|
+
message: formatNoReadyCodexPetsMessage(pets, home),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
short: "No ready custom Codex pet found.",
|
|
452
|
+
message: formatNoReadyCodexPetsMessage(pets, home),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function formatNoReadyCodexPetsMessage(pets: CodexPetPackage[], home = codexHome()): string {
|
|
457
|
+
if (pets.length === 0) {
|
|
458
|
+
return [
|
|
459
|
+
"No custom Codex pets found.",
|
|
460
|
+
"",
|
|
461
|
+
`Looked in: ${codexPetsDir(home)}`,
|
|
462
|
+
"",
|
|
463
|
+
formatCodexPetSetupInstructions(home),
|
|
464
|
+
].join("\n");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return [
|
|
468
|
+
"Found custom Codex pets, but none are ready.",
|
|
469
|
+
"",
|
|
470
|
+
`A ready pet needs pet.json and a readable ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT} ${DEFAULT_SPRITESHEET_PATH} atlas in its pet folder.`,
|
|
471
|
+
"Run /pets list to see what needs fixing, or create a new pet:",
|
|
472
|
+
"",
|
|
473
|
+
formatCodexPetSetupInstructions(home),
|
|
474
|
+
].join("\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function kittyImageBaseForPet(slug: string): number {
|
|
478
|
+
let hash = 0;
|
|
479
|
+
for (const char of slug) hash = (hash * 31 + char.charCodeAt(0)) % 0xffff;
|
|
480
|
+
return 0x50000000 + hash * 100;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function findCodexPetDirect(
|
|
484
|
+
slug: string,
|
|
485
|
+
home: string,
|
|
486
|
+
): Promise<CodexPetPackage | undefined> {
|
|
487
|
+
const request = petLookupRequest(slug);
|
|
488
|
+
if (!request.key) return undefined;
|
|
489
|
+
const { dir, entries } = await readPetDirectoryEntries(home);
|
|
490
|
+
const candidates = await readCodexPetPackages(dir, entries, { validateSpritesheet: false });
|
|
491
|
+
const candidate = findCodexPet(candidates, slug);
|
|
492
|
+
if (!candidate || candidate.spritesheetIssue) return candidate;
|
|
493
|
+
const spritesheetIssue = await validatePetSpritesheet(candidate.dir, candidate.spritesheetPath);
|
|
494
|
+
return {
|
|
495
|
+
...candidate,
|
|
496
|
+
hasSpritesheet: spritesheetIssue === undefined,
|
|
497
|
+
spritesheetIssue,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function loadCodexPet(
|
|
502
|
+
slug?: string,
|
|
503
|
+
home = codexHome(),
|
|
504
|
+
options: { sizeCells?: number } = {},
|
|
505
|
+
): Promise<LoadedCodexPet | undefined> {
|
|
506
|
+
const pet = slug
|
|
507
|
+
? await findCodexPetDirect(slug, home)
|
|
508
|
+
: selectPet(await listCodexPets(home), slug);
|
|
509
|
+
if (!pet?.hasSpritesheet) return undefined;
|
|
510
|
+
|
|
511
|
+
const spritesheetPath = resolvePetAssetPath(pet.dir, pet.spritesheetPath);
|
|
512
|
+
if (!spritesheetPath)
|
|
513
|
+
throw new Error(`Invalid spritesheetPath outside pet folder: ${pet.spritesheetPath}`);
|
|
514
|
+
const source = sharp(spritesheetPath, { animated: false });
|
|
515
|
+
const metadata = await source.metadata();
|
|
516
|
+
if (metadata.width !== EXPECTED_ATLAS_WIDTH || metadata.height !== EXPECTED_ATLAS_HEIGHT) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`Invalid Codex pet atlas dimensions: ${metadata.width ?? "?"}x${metadata.height ?? "?"}; expected ${EXPECTED_ATLAS_WIDTH}x${EXPECTED_ATLAS_HEIGHT}.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
const states = {} as Record<PetState, PetFrame[]>;
|
|
522
|
+
const imageProtocol = getCapabilities().images;
|
|
523
|
+
const useKitty = imageProtocol === "kitty";
|
|
524
|
+
const kittyImageBase = kittyImageBaseForPet(pet.slug);
|
|
525
|
+
let kittyFrameOffset = 1;
|
|
526
|
+
const cellDimensions = getCellDimensions();
|
|
527
|
+
const targetWidthPx = options.sizeCells
|
|
528
|
+
? Math.max(1, Math.round(options.sizeCells * cellDimensions.widthPx))
|
|
529
|
+
: undefined;
|
|
530
|
+
const targetHeightPx = targetWidthPx
|
|
531
|
+
? Math.max(
|
|
532
|
+
1,
|
|
533
|
+
Math.ceil(
|
|
534
|
+
(DEFAULT_CELL_HEIGHT * targetWidthPx) / DEFAULT_CELL_WIDTH / cellDimensions.heightPx,
|
|
535
|
+
) * cellDimensions.heightPx,
|
|
536
|
+
)
|
|
537
|
+
: undefined;
|
|
538
|
+
const outputWidthPx = targetWidthPx ?? DEFAULT_CELL_WIDTH;
|
|
539
|
+
const outputHeightPx = targetHeightPx ?? DEFAULT_CELL_HEIGHT;
|
|
540
|
+
|
|
541
|
+
for (const [state, animation] of Object.entries(PET_ANIMATION_ROWS) as Array<
|
|
542
|
+
[PetState, (typeof PET_ANIMATION_ROWS)[PetState]]
|
|
543
|
+
>) {
|
|
544
|
+
states[state] = [];
|
|
545
|
+
for (let column = 0; column < animation.durations.length; column++) {
|
|
546
|
+
const durationMs = animation.durations[column] ?? 150;
|
|
547
|
+
if (!imageProtocol) {
|
|
548
|
+
states[state].push({
|
|
549
|
+
mimeType: "image/png",
|
|
550
|
+
durationMs,
|
|
551
|
+
widthPx: outputWidthPx,
|
|
552
|
+
heightPx: outputHeightPx,
|
|
553
|
+
});
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let frame = source.clone().extract({
|
|
558
|
+
left: column * DEFAULT_CELL_WIDTH,
|
|
559
|
+
top: animation.row * DEFAULT_CELL_HEIGHT,
|
|
560
|
+
width: DEFAULT_CELL_WIDTH,
|
|
561
|
+
height: DEFAULT_CELL_HEIGHT,
|
|
562
|
+
});
|
|
563
|
+
if (targetWidthPx && targetHeightPx) {
|
|
564
|
+
frame = frame.resize(targetWidthPx, targetHeightPx, {
|
|
565
|
+
fit: "fill",
|
|
566
|
+
kernel: sharp.kernel.nearest,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const kittyImageId = useKitty ? kittyImageBase + kittyFrameOffset++ : undefined;
|
|
570
|
+
const encoded = useKitty
|
|
571
|
+
? {
|
|
572
|
+
kittyUpload: encodeKittyRawRgbaData(
|
|
573
|
+
(await frame.ensureAlpha().raw().toBuffer()).toString("base64"),
|
|
574
|
+
outputWidthPx,
|
|
575
|
+
outputHeightPx,
|
|
576
|
+
kittyImageId!,
|
|
577
|
+
),
|
|
578
|
+
}
|
|
579
|
+
: { data: (await frame.png().toBuffer()).toString("base64") };
|
|
580
|
+
states[state].push({
|
|
581
|
+
...encoded,
|
|
582
|
+
kittyImageId,
|
|
583
|
+
mimeType: "image/png",
|
|
584
|
+
durationMs,
|
|
585
|
+
widthPx: outputWidthPx,
|
|
586
|
+
heightPx: outputHeightPx,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { pet, states };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function animationCursor(frames: PetFrame[], now = Date.now(), durationMultiplier = 1) {
|
|
595
|
+
const multiplier = Math.max(0.1, durationMultiplier);
|
|
596
|
+
const total = frames.reduce((sum, frame) => sum + frame.durationMs * multiplier, 0);
|
|
597
|
+
return { multiplier, total, cursor: total > 0 ? now % total : 0 };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function animationFrameAt(
|
|
601
|
+
frames: PetFrame[],
|
|
602
|
+
now = Date.now(),
|
|
603
|
+
durationMultiplier = 1,
|
|
604
|
+
): PetFrame | undefined {
|
|
605
|
+
if (frames.length === 0) return undefined;
|
|
606
|
+
let { cursor, multiplier } = animationCursor(frames, now, durationMultiplier);
|
|
607
|
+
for (const frame of frames) {
|
|
608
|
+
cursor -= frame.durationMs * multiplier;
|
|
609
|
+
if (cursor < 0) return frame;
|
|
610
|
+
}
|
|
611
|
+
return frames[0];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function nextAnimationFrameDelayMs(
|
|
615
|
+
frames: PetFrame[],
|
|
616
|
+
now = Date.now(),
|
|
617
|
+
durationMultiplier = 1,
|
|
618
|
+
): number {
|
|
619
|
+
if (frames.length === 0) return 120;
|
|
620
|
+
let { cursor, multiplier } = animationCursor(frames, now, durationMultiplier);
|
|
621
|
+
for (const frame of frames) {
|
|
622
|
+
const duration = frame.durationMs * multiplier;
|
|
623
|
+
if (cursor < duration) return Math.max(1, Math.ceil(duration - cursor));
|
|
624
|
+
cursor -= duration;
|
|
625
|
+
}
|
|
626
|
+
return Math.max(1, Math.ceil(frames[0]!.durationMs));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function forEachCodexPetFrame(
|
|
630
|
+
pet: LoadedCodexPet | undefined,
|
|
631
|
+
visit: (frame: PetFrame) => void,
|
|
632
|
+
): void {
|
|
633
|
+
if (!pet) return;
|
|
634
|
+
for (const frames of Object.values(pet.states)) {
|
|
635
|
+
for (const frame of frames) visit(frame);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function codexPetKittyImageIds(pet?: LoadedCodexPet): number[] {
|
|
640
|
+
const imageIds = new Set<number>();
|
|
641
|
+
forEachCodexPetFrame(pet, (frame) => {
|
|
642
|
+
if (frame.kittyImageId !== undefined) imageIds.add(frame.kittyImageId);
|
|
643
|
+
});
|
|
644
|
+
return Array.from(imageIds);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function deleteCodexPetKittyPlacement(imageId: number): string {
|
|
648
|
+
return `\x1b_Ga=d,d=i,i=${imageId},p=1,q=2\x1b\\`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function deleteCodexPetKittyImage(imageId: number): string {
|
|
652
|
+
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function placeKittyImage(imageId: number, columns: number, rows: number): string {
|
|
656
|
+
return `\x1b_Ga=p,i=${imageId},p=1,c=${columns},r=${rows},q=2,C=1\x1b\\`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function encodeKittyRawRgbaData(
|
|
660
|
+
rawRgbaData: string,
|
|
661
|
+
widthPx: number,
|
|
662
|
+
heightPx: number,
|
|
663
|
+
imageId: number,
|
|
664
|
+
): string {
|
|
665
|
+
if (!rawRgbaData) return "";
|
|
666
|
+
const chunkSize = 4096;
|
|
667
|
+
const params = ["a=t", "f=32", `s=${widthPx}`, `v=${heightPx}`, `i=${imageId}`, "q=2"].join(",");
|
|
668
|
+
if (rawRgbaData.length <= chunkSize) {
|
|
669
|
+
return `\x1b_G${params};${rawRgbaData}\x1b\\`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const chunks: string[] = [];
|
|
673
|
+
for (let offset = 0; offset < rawRgbaData.length; offset += chunkSize) {
|
|
674
|
+
const chunk = rawRgbaData.slice(offset, offset + chunkSize);
|
|
675
|
+
const isFirstChunk = offset === 0;
|
|
676
|
+
const isLastChunk = offset + chunkSize >= rawRgbaData.length;
|
|
677
|
+
if (isFirstChunk) chunks.push(`\x1b_G${params},m=1;${chunk}\x1b\\`);
|
|
678
|
+
else chunks.push(`\x1b_Gm=${isLastChunk ? 0 : 1};${chunk}\x1b\\`);
|
|
679
|
+
}
|
|
680
|
+
return chunks.join("");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
|
|
684
|
+
return (
|
|
685
|
+
frame.kittyUpload ??
|
|
686
|
+
(frame.rawRgbaData
|
|
687
|
+
? encodeKittyRawRgbaData(frame.rawRgbaData, frame.widthPx, frame.heightPx, imageId)
|
|
688
|
+
: "")
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export class CodexPetKittyManager {
|
|
693
|
+
private previousFrameImageId: number | undefined;
|
|
694
|
+
private readonly pendingCleanupImageIds = new Set<number>();
|
|
695
|
+
readonly placementImageId: number;
|
|
696
|
+
|
|
697
|
+
constructor(placementImageId: number) {
|
|
698
|
+
this.placementImageId = placementImageId;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
queueCleanup(pet?: LoadedCodexPet): void {
|
|
702
|
+
for (const imageId of codexPetKittyImageIds(pet)) this.pendingCleanupImageIds.add(imageId);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
invalidate(pet?: LoadedCodexPet): void {
|
|
706
|
+
this.queueCleanup(pet);
|
|
707
|
+
this.previousFrameImageId = undefined;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
resetForResize(pet?: LoadedCodexPet): void {
|
|
711
|
+
this.invalidate(pet);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
dispose(pet?: LoadedCodexPet): string {
|
|
715
|
+
this.invalidate(pet);
|
|
716
|
+
return this.takeCleanupSequence();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
takeCleanupSequence(): string {
|
|
720
|
+
if (this.pendingCleanupImageIds.size === 0) return "";
|
|
721
|
+
const sequence = `${deleteCodexPetKittyPlacement(this.placementImageId)}${Array.from(
|
|
722
|
+
this.pendingCleanupImageIds,
|
|
723
|
+
)
|
|
724
|
+
.map((imageId) => deleteCodexPetKittyImage(imageId))
|
|
725
|
+
.join("")}`;
|
|
726
|
+
this.pendingCleanupImageIds.clear();
|
|
727
|
+
return sequence;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
renderFrame(frame: PetFrame, width: number, options: { sizeCells: number }): string[] {
|
|
731
|
+
const columns = Math.max(1, Math.min(width - 2, options.sizeCells));
|
|
732
|
+
const rows = calculateImageRows(
|
|
733
|
+
{ widthPx: frame.widthPx, heightPx: frame.heightPx },
|
|
734
|
+
columns,
|
|
735
|
+
getCellDimensions(),
|
|
736
|
+
);
|
|
737
|
+
const frameImageId = frame.kittyImageId ?? this.placementImageId;
|
|
738
|
+
const deletePrevious =
|
|
739
|
+
this.previousFrameImageId !== undefined && this.previousFrameImageId !== frameImageId
|
|
740
|
+
? deleteCodexPetKittyPlacement(this.previousFrameImageId)
|
|
741
|
+
: "";
|
|
742
|
+
const deleteCurrent = deleteCodexPetKittyPlacement(frameImageId);
|
|
743
|
+
// Do not cache uploads across animation loops. Kitty-compatible terminals may
|
|
744
|
+
// drop image data after clears, quota pressure, or after a placement is
|
|
745
|
+
// deleted; failures are suppressed with q=2 and otherwise show up as an
|
|
746
|
+
// occasionally invisible pet. The TUI diff still avoids writing this payload
|
|
747
|
+
// again when the rendered line is unchanged.
|
|
748
|
+
const upload = encodeKittyRawRgba(frame, frameImageId);
|
|
749
|
+
this.previousFrameImageId = frameImageId;
|
|
750
|
+
// Recent pi-tui versions automatically free Kitty image IDs they own on changed lines.
|
|
751
|
+
// Pet frames are managed here and intentionally reused across animation loops, so keep
|
|
752
|
+
// the first Kitty command pointed at a harmless footer-placement ID, not a frame ID.
|
|
753
|
+
const hostCleanupSentinel =
|
|
754
|
+
frameImageId !== this.placementImageId
|
|
755
|
+
? deleteCodexPetKittyPlacement(this.placementImageId)
|
|
756
|
+
: "";
|
|
757
|
+
const sequence = `${hostCleanupSentinel}${deletePrevious}${deleteCurrent}${upload}${placeKittyImage(frameImageId, columns, rows)}`;
|
|
758
|
+
const lines: string[] = [];
|
|
759
|
+
for (let i = 0; i < rows - 1; i++) lines.push("");
|
|
760
|
+
const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
|
|
761
|
+
const moveDown = rows > 1 ? `\x1b[${rows - 1}B` : "";
|
|
762
|
+
lines.push(moveUp + sequence + moveDown);
|
|
763
|
+
return lines;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const defaultKittyManagers = new Map<number, CodexPetKittyManager>();
|
|
768
|
+
|
|
769
|
+
function defaultKittyManager(placementImageId: number): CodexPetKittyManager {
|
|
770
|
+
let manager = defaultKittyManagers.get(placementImageId);
|
|
771
|
+
if (!manager) {
|
|
772
|
+
manager = new CodexPetKittyManager(placementImageId);
|
|
773
|
+
defaultKittyManagers.set(placementImageId, manager);
|
|
774
|
+
}
|
|
775
|
+
return manager;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export function resetCodexPetKittyCache(pet?: LoadedCodexPet, placementImageId?: number): void {
|
|
779
|
+
if (placementImageId !== undefined) defaultKittyManagers.get(placementImageId)?.invalidate(pet);
|
|
780
|
+
else for (const manager of defaultKittyManagers.values()) manager.invalidate(pet);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function renderCodexPetFrame(
|
|
784
|
+
pet: LoadedCodexPet,
|
|
785
|
+
state: PetState,
|
|
786
|
+
width: number,
|
|
787
|
+
theme: ThemeLike,
|
|
788
|
+
options: {
|
|
789
|
+
sizeCells: number;
|
|
790
|
+
imageId: number;
|
|
791
|
+
now?: number;
|
|
792
|
+
durationMultiplier?: number;
|
|
793
|
+
kittyManager?: CodexPetKittyManager;
|
|
794
|
+
},
|
|
795
|
+
): string[] {
|
|
796
|
+
const frame = animationFrameAt(
|
|
797
|
+
pet.states[state] ?? pet.states.idle,
|
|
798
|
+
options.now,
|
|
799
|
+
options.durationMultiplier,
|
|
800
|
+
);
|
|
801
|
+
if (!frame) return [];
|
|
802
|
+
const imageProtocol = getCapabilities().images;
|
|
803
|
+
if (imageProtocol === "kitty") {
|
|
804
|
+
const manager = options.kittyManager ?? defaultKittyManager(options.imageId);
|
|
805
|
+
return manager.renderFrame(frame, width, { sizeCells: options.sizeCells });
|
|
806
|
+
}
|
|
807
|
+
if (!imageProtocol) {
|
|
808
|
+
const fallback = imageFallback(frame.mimeType, {
|
|
809
|
+
widthPx: frame.widthPx,
|
|
810
|
+
heightPx: frame.heightPx,
|
|
811
|
+
});
|
|
812
|
+
return [truncateToWidth(theme.fg("dim", fallback), width, theme.fg("dim", "..."))];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (!frame.data) return [];
|
|
816
|
+
const imageTheme: ImageTheme = { fallbackColor: (value) => theme.fg("dim", value) };
|
|
817
|
+
const image = new Image(
|
|
818
|
+
frame.data,
|
|
819
|
+
frame.mimeType,
|
|
820
|
+
imageTheme,
|
|
821
|
+
{ maxWidthCells: Math.min(options.sizeCells, width), imageId: options.imageId },
|
|
822
|
+
{ widthPx: frame.widthPx, heightPx: frame.heightPx },
|
|
823
|
+
);
|
|
824
|
+
return image.render(width);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function formatCodexPetsHelp(home = codexHome()): string {
|
|
828
|
+
return [
|
|
829
|
+
"Codex pets can now render in the pi Better OpenAI footer too.",
|
|
830
|
+
"",
|
|
831
|
+
"Commands:",
|
|
832
|
+
` /${PETS_COMMAND} wake [slug] Render a custom Codex pet in the footer`,
|
|
833
|
+
` /${PETS_COMMAND} tuck Hide the footer pet`,
|
|
834
|
+
` /${PETS_COMMAND} select <slug> Select a pet without changing visibility`,
|
|
835
|
+
` /${PETS_COMMAND} list List local custom pets`,
|
|
836
|
+
"",
|
|
837
|
+
formatCodexPetSetupInstructions(home),
|
|
838
|
+
"",
|
|
839
|
+
"The Codex app overlay is still controlled by Codex Settings → Appearance → Pets or /pet.",
|
|
840
|
+
].join("\n");
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const PET_SUBCOMMANDS: AutocompleteItem[] = [
|
|
844
|
+
{ value: "help", label: "help", description: "Show Codex pets setup and usage help" },
|
|
845
|
+
{ value: "list", label: "list", description: "List local custom Codex pets" },
|
|
846
|
+
{ value: "wake", label: "wake", description: "Render a footer pet" },
|
|
847
|
+
{ value: "tuck", label: "tuck", description: "Hide the footer pet" },
|
|
848
|
+
{
|
|
849
|
+
value: "select",
|
|
850
|
+
label: "select",
|
|
851
|
+
description: "Select a footer pet without changing visibility",
|
|
852
|
+
},
|
|
853
|
+
];
|
|
854
|
+
|
|
855
|
+
function petCompletionValue(subcommand: string, pet: CodexPetPackage): string {
|
|
856
|
+
return `${subcommand} ${pet.slug}`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function petContainsLookup(pet: CodexPetPackage, query: string): boolean {
|
|
860
|
+
return (
|
|
861
|
+
!query ||
|
|
862
|
+
petLookupKey(pet.slug).includes(query) ||
|
|
863
|
+
(pet.id !== undefined && petLookupKey(pet.id).includes(query)) ||
|
|
864
|
+
petLookupKey(pet.name).includes(query)
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export async function openAIPetsArgumentCompletions(
|
|
869
|
+
argumentPrefix: string,
|
|
870
|
+
home = codexHome(),
|
|
871
|
+
): Promise<AutocompleteItem[] | null> {
|
|
872
|
+
const normalizedPrefix = argumentPrefix.trimStart();
|
|
873
|
+
const parts = normalizedPrefix.split(/\s+/).filter(Boolean);
|
|
874
|
+
const hasTrailingSpace = /\s$/.test(normalizedPrefix);
|
|
875
|
+
const subcommand = parts[0]?.toLowerCase();
|
|
876
|
+
|
|
877
|
+
if (!subcommand || (!hasTrailingSpace && parts.length <= 1)) {
|
|
878
|
+
const query = subcommand ?? "";
|
|
879
|
+
const matches = PET_SUBCOMMANDS.filter((item) => item.value.startsWith(query));
|
|
880
|
+
return matches.length > 0 ? matches : null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (subcommand !== "wake" && subcommand !== "select") return null;
|
|
884
|
+
|
|
885
|
+
const petQuery = hasTrailingSpace ? "" : parts.slice(1).join(" ");
|
|
886
|
+
const normalizedPetQuery = petLookupKey(petQuery);
|
|
887
|
+
const pets = (await listCodexPets(home)).filter((pet) => pet.hasSpritesheet);
|
|
888
|
+
const matches = pets.filter((pet) => petContainsLookup(pet, normalizedPetQuery));
|
|
889
|
+
return matches.length > 0
|
|
890
|
+
? matches.map((pet) => ({
|
|
891
|
+
value: petCompletionValue(subcommand, pet),
|
|
892
|
+
label: pet.name,
|
|
893
|
+
description: `${pet.slug}${pet.description ? ` — ${pet.description}` : ""}`,
|
|
894
|
+
}))
|
|
895
|
+
: null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function codexPetStatus(pet: CodexPetPackage): string {
|
|
899
|
+
if (pet.hasSpritesheet) return "ready";
|
|
900
|
+
return pet.spritesheetIssue ?? `missing ${pet.spritesheetPath}`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function formatCodexPetListLine(pet: CodexPetPackage): string {
|
|
904
|
+
const description = pet.description ? `\n ${pet.description}` : "";
|
|
905
|
+
return `${pet.name} (${pet.slug}) — ${codexPetStatus(pet)}${description}`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export function formatCodexPetsListMessage(pets: CodexPetPackage[], home = codexHome()): string {
|
|
909
|
+
if (pets.length === 0) return formatNoReadyCodexPetsMessage(pets, home);
|
|
910
|
+
|
|
911
|
+
const petLines = pets.map(formatCodexPetListLine).join("\n");
|
|
912
|
+
|
|
913
|
+
if (pets.some((pet) => pet.hasSpritesheet)) return petLines;
|
|
914
|
+
|
|
915
|
+
return [
|
|
916
|
+
"Found custom Codex pets, but none are ready.",
|
|
917
|
+
"",
|
|
918
|
+
petLines,
|
|
919
|
+
"",
|
|
920
|
+
"Fix one of the pet folders above, or create a new pet:",
|
|
921
|
+
"",
|
|
922
|
+
formatCodexPetSetupInstructions(home),
|
|
923
|
+
].join("\n");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function notifyPetsList(ctx: ExtensionContext): Promise<void> {
|
|
927
|
+
const home = codexHome();
|
|
928
|
+
const pets = await listCodexPets(home, { refresh: true });
|
|
929
|
+
ctx.ui.notify(
|
|
930
|
+
formatCodexPetsListMessage(pets, home),
|
|
931
|
+
pets.some((pet) => pet.hasSpritesheet) ? "info" : "warning",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export function registerOpenAIPets(pi: ExtensionAPI, controller: PetsCommandController = {}): void {
|
|
936
|
+
pi.registerCommand(PETS_COMMAND, {
|
|
937
|
+
description: "Render, configure, or list Codex pets for the Better OpenAI footer",
|
|
938
|
+
getArgumentCompletions: openAIPetsArgumentCompletions,
|
|
939
|
+
handler: async (args, ctx) => {
|
|
940
|
+
const [subcommand = "help", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
941
|
+
const normalized = subcommand.toLowerCase();
|
|
942
|
+
const slug = rest.join(" ").trim() || undefined;
|
|
943
|
+
switch (normalized) {
|
|
944
|
+
case "help":
|
|
945
|
+
ctx.ui.notify(formatCodexPetsHelp(), "info");
|
|
946
|
+
return;
|
|
947
|
+
case "list":
|
|
948
|
+
await notifyPetsList(ctx);
|
|
949
|
+
return;
|
|
950
|
+
case "wake":
|
|
951
|
+
if (controller.wake) {
|
|
952
|
+
await controller.wake(ctx, slug);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
break;
|
|
956
|
+
case "tuck":
|
|
957
|
+
if (controller.tuck) {
|
|
958
|
+
await controller.tuck(ctx);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
break;
|
|
962
|
+
case "select":
|
|
963
|
+
if (controller.select) {
|
|
964
|
+
await controller.select(ctx, slug);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
ctx.ui.notify(`Usage: /${PETS_COMMAND} [help|list|wake [slug]|tuck|select <slug>]`, "error");
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export const _petsTest = {
|
|
975
|
+
PETS_COMMAND,
|
|
976
|
+
codexPetsDir,
|
|
977
|
+
petInfoFromJson,
|
|
978
|
+
petLookupKey,
|
|
979
|
+
findCodexPet,
|
|
980
|
+
findReadyCodexPet,
|
|
981
|
+
selectPet,
|
|
982
|
+
clearPetCatalogCache,
|
|
983
|
+
};
|