pi-sprite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +7 -0
  4. package/README.md +353 -0
  5. package/examples/README.md +15 -0
  6. package/examples/custom-pets/wumpus-template/README.md +21 -0
  7. package/examples/custom-pets/wumpus-template/pet.json +14 -0
  8. package/examples/petdex-downloads/.gitkeep +0 -0
  9. package/extensions/index.ts +104 -0
  10. package/package.json +77 -0
  11. package/skills/pi-sprite-authoring/SKILL.md +330 -0
  12. package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
  13. package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
  14. package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
  15. package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
  16. package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
  17. package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
  18. package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
  19. package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
  20. package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
  21. package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
  22. package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
  23. package/src/agent/session-entries.ts +28 -0
  24. package/src/agent/side-completion.ts +94 -0
  25. package/src/agent/side-session-text.ts +20 -0
  26. package/src/agent/side-session-types.ts +12 -0
  27. package/src/agent/side-session.ts +107 -0
  28. package/src/btw/completion.ts +15 -0
  29. package/src/btw/format.ts +30 -0
  30. package/src/btw/index.ts +306 -0
  31. package/src/btw/prompt.ts +35 -0
  32. package/src/btw/recap.ts +56 -0
  33. package/src/btw/session.ts +37 -0
  34. package/src/btw/thread-store.ts +37 -0
  35. package/src/context/index.ts +343 -0
  36. package/src/recap/conversation.ts +22 -0
  37. package/src/recap/direct.ts +15 -0
  38. package/src/recap/format.ts +25 -0
  39. package/src/recap/generation.ts +58 -0
  40. package/src/recap/index.ts +86 -0
  41. package/src/sprite/commands.ts +205 -0
  42. package/src/sprite/download.ts +75 -0
  43. package/src/sprite/kitty-placeholder.ts +441 -0
  44. package/src/sprite/live-status-format.ts +67 -0
  45. package/src/sprite/live-status.ts +48 -0
  46. package/src/sprite/loader.ts +134 -0
  47. package/src/sprite/manifest.ts +87 -0
  48. package/src/sprite/paths.ts +8 -0
  49. package/src/sprite/petdex.ts +133 -0
  50. package/src/sprite/renderer.ts +491 -0
  51. package/src/sprite/runtime.ts +524 -0
  52. package/src/sprite/turn-status-format.ts +112 -0
  53. package/src/sprite/turn-status.ts +88 -0
  54. package/src/ui/overlay.ts +308 -0
@@ -0,0 +1,524 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { SpriteBubblePlacement } from "../ui/overlay.ts";
6
+ import { registerSpriteCommands, type SpriteCommandRuntime } from "./commands.ts";
7
+ import { downloadToBuffer } from "./download.ts";
8
+ import { formatLiveStatusFooter, type LiveTurnStatus } from "./live-status-format.ts";
9
+ import { importPetZip, loadPet } from "./loader.ts";
10
+ import type { SpriteState } from "./manifest.ts";
11
+ import { spriteHome, statePath } from "./paths.ts";
12
+
13
+ import {
14
+ buildKittyPlaceholderSpriteWidget,
15
+ buildNativeSpriteWidget,
16
+ clearAllNativeSpriteImages,
17
+ clearNativeSpriteImages,
18
+ clearSpriteTerminalGraphicsCaches,
19
+ formatNativeSpritePlaceholderLines,
20
+ formatTextSpriteLines,
21
+ renderSpriteAnimation,
22
+ type SpriteAlign,
23
+ type SpriteRenderOptions,
24
+ type SpriteSize,
25
+ supportsNativeSpriteImages,
26
+ usesKittyPlaceholderSpriteImages,
27
+ } from "./renderer.ts";
28
+ import { formatTurnStatusFooter, type TurnStatus } from "./turn-status-format.ts";
29
+
30
+ type ActivityStatus = "idle" | "running" | "ready" | "error";
31
+
32
+ interface SavedState {
33
+ selectedPetId?: string;
34
+ visible?: boolean;
35
+ size?: SpriteSize;
36
+ label?: boolean;
37
+ align?: SpriteAlign;
38
+ turnStatusEnabled?: boolean;
39
+ turnStatusConfigured?: boolean;
40
+ liveStatusEnabled?: boolean;
41
+ liveStatusConfigured?: boolean;
42
+ }
43
+
44
+ interface CompanionActivity {
45
+ btwCount: number;
46
+ btwStatus: ActivityStatus;
47
+ recapStatus: ActivityStatus;
48
+ }
49
+
50
+ const DEFAULT_FRAMES: Record<SpriteState, string> = {
51
+ idle: " ◕‿◕ ",
52
+ thinking: " ◔_◔ ",
53
+ working: " ◕_◕⌨ ",
54
+ success: " ◕‿◕✦ ",
55
+ error: " ◕︵◕ ",
56
+ };
57
+
58
+ const DEFAULT_SIZE: SpriteSize = "small";
59
+ const DEFAULT_ALIGN: SpriteAlign = "right";
60
+ const DEFAULT_LABEL = false;
61
+ const DEFAULT_TURN_STATUS_ENABLED = true;
62
+ const DEFAULT_LIVE_STATUS_ENABLED = true;
63
+
64
+ function loadSaved(): SavedState {
65
+ try {
66
+ return JSON.parse(readFileSync(statePath(), "utf8")) as SavedState;
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+ function saveSaved(state: SavedState): void {
72
+ mkdirSync(spriteHome(), { recursive: true });
73
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
74
+ }
75
+
76
+ function nativeImageIdFromSeed(seed: string, mask = 0x7ffffffe): number {
77
+ const digest = createHash("sha1").update(seed).digest();
78
+ return digest.readUInt32BE(0) & mask || 1;
79
+ }
80
+
81
+ export function stableNativeImageId(): number {
82
+ const paneOrCwd = process.env.TMUX_PANE ? `pane:${process.env.TMUX_PANE}` : `cwd:${process.cwd()}`;
83
+ return nativeImageIdFromSeed(["pi-sprite", paneOrCwd].join(":"));
84
+ }
85
+
86
+ function legacyNativeImageId(): number {
87
+ const legacySeed = ["pi-sprite", process.env.TMUX_PANE ?? "no-pane", process.cwd()].join(":");
88
+ return nativeImageIdFromSeed(legacySeed, 0x7fffffff);
89
+ }
90
+
91
+ export function nativeSpriteCleanupImageIds(count = 16): number[] {
92
+ const imageId = stableNativeImageId();
93
+ const frameIds = Array.from({ length: Math.max(2, count) }, (_unused, index) => imageId + index);
94
+ return Array.from(new Set([...frameIds, legacyNativeImageId()]));
95
+ }
96
+
97
+ export function createSpriteRuntime() {
98
+ let ctx: ExtensionContext | undefined;
99
+ let state: SpriteState = "idle";
100
+ let selectedPetId = "";
101
+ let visible = true;
102
+ let size: SpriteSize = DEFAULT_SIZE;
103
+ let label = DEFAULT_LABEL;
104
+ let align: SpriteAlign = DEFAULT_ALIGN;
105
+ let turnStatusEnabled = DEFAULT_TURN_STATUS_ENABLED;
106
+ let turnStatusConfigured = false;
107
+ let turnStatus: TurnStatus | "pending" | undefined;
108
+ let liveStatusEnabled = DEFAULT_LIVE_STATUS_ENABLED;
109
+ let liveStatusConfigured = false;
110
+ let liveStatus: LiveTurnStatus | "pending" | undefined;
111
+ let liveStatusGeneration = 0;
112
+ let activity: CompanionActivity = { btwCount: 0, btwStatus: "idle", recapStatus: "idle" };
113
+ let resetTimer: ReturnType<typeof setTimeout> | undefined;
114
+ let clearWidgetTimer: ReturnType<typeof setTimeout> | undefined;
115
+ let animationTimer: ReturnType<typeof setInterval> | undefined;
116
+ let lastSignature = "";
117
+ let renderGeneration = 0;
118
+ let frameIndex = 0;
119
+ let previousNativeFrameImageId: number | undefined;
120
+ let clearedStaleNativeImages = false;
121
+ let trackedNativeImageIds = nativeSpriteCleanupImageIds();
122
+
123
+ function currentNativeImageIds(count = 2): number[] {
124
+ const ids = nativeSpriteCleanupImageIds(count);
125
+ trackedNativeImageIds = Array.from(new Set([...trackedNativeImageIds, ...ids]));
126
+ return ids;
127
+ }
128
+
129
+ function clearTrackedNativeSpriteImages(): string[] {
130
+ const currentIds = nativeSpriteCleanupImageIds();
131
+ const ids = Array.from(new Set([...trackedNativeImageIds, ...currentIds]));
132
+ trackedNativeImageIds = currentIds;
133
+ clearSpriteTerminalGraphicsCaches();
134
+ return clearNativeSpriteImages(ids);
135
+ }
136
+
137
+ function selectedName(): string {
138
+ return selectedPetId ? (loadPet(selectedPetId)?.manifest.name ?? selectedPetId) : "default";
139
+ }
140
+
141
+ function selectedPersonality(): string | undefined {
142
+ return selectedPetId ? loadPet(selectedPetId)?.manifest.personality : undefined;
143
+ }
144
+
145
+ function renderOptions(): SpriteRenderOptions {
146
+ return { size, label, align };
147
+ }
148
+
149
+ function defaultLines(): string[] {
150
+ const lines = [DEFAULT_FRAMES[state]];
151
+ if (label) lines.push(`pi-sprite · ${state}`);
152
+ return lines;
153
+ }
154
+
155
+ function updateFooter(currentCtx = ctx): void {
156
+ if (!currentCtx?.hasUI) return;
157
+ if (!visible) {
158
+ currentCtx.ui.setStatus("pi-sprite", undefined);
159
+ return;
160
+ }
161
+ const parts = [`🐾 ${selectedName()} ${state}`];
162
+ if (liveStatusEnabled && liveStatus) {
163
+ parts.push(liveStatus === "pending" ? "live…" : formatLiveStatusFooter(liveStatus));
164
+ }
165
+ if (turnStatusEnabled && turnStatus) {
166
+ parts.push(turnStatus === "pending" ? "status…" : formatTurnStatusFooter(turnStatus));
167
+ }
168
+ currentCtx.ui.setStatus("pi-sprite", parts.join(" · "));
169
+ }
170
+
171
+ function spriteBubbleBottomMargin(): number {
172
+ const base = { tiny: 4, small: 6, medium: 8, large: 10 }[size];
173
+ return base + (label ? 1 : 0);
174
+ }
175
+
176
+ function bubblePlacement(): SpriteBubblePlacement {
177
+ if (!visible) return { anchor: "center", tail: "none", margin: {} };
178
+ if (align === "left")
179
+ return { anchor: "bottom-left", tail: "bottom-left", margin: { left: 2, bottom: spriteBubbleBottomMargin() } };
180
+ return { anchor: "bottom-right", tail: "bottom-right", margin: { right: 2, bottom: spriteBubbleBottomMargin() } };
181
+ }
182
+
183
+ function stopAnimation(): void {
184
+ if (animationTimer) clearInterval(animationTimer);
185
+ animationTimer = undefined;
186
+ }
187
+
188
+ function clearNativeWidget(
189
+ currentCtx: ExtensionContext,
190
+ options: { removeAfter?: boolean; requireCurrentContext?: boolean; trackTimer?: boolean } = {},
191
+ ): void {
192
+ if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
193
+ previousNativeFrameImageId = undefined;
194
+ const clearLines = clearTrackedNativeSpriteImages();
195
+ if (!clearLines.length) {
196
+ currentCtx.ui.setWidget("pi-sprite", undefined, { placement: "belowEditor" });
197
+ return;
198
+ }
199
+ currentCtx.ui.setWidget("pi-sprite", clearLines, { placement: "belowEditor" });
200
+ if (options.removeAfter === false) return;
201
+ const timer = setTimeout(() => {
202
+ if (options.requireCurrentContext === false || ctx === currentCtx) {
203
+ currentCtx.ui.setWidget("pi-sprite", undefined, { placement: "belowEditor" });
204
+ }
205
+ }, 50);
206
+ if (options.trackTimer !== false) clearWidgetTimer = timer;
207
+ }
208
+
209
+ function render(): void {
210
+ if (!ctx?.hasUI) return;
211
+ const currentCtx = ctx;
212
+ updateFooter(currentCtx);
213
+ let startupClearLines: string[] = [];
214
+ if (!clearedStaleNativeImages) {
215
+ clearedStaleNativeImages = true;
216
+ previousNativeFrameImageId = undefined;
217
+ startupClearLines = clearTrackedNativeSpriteImages();
218
+ }
219
+ const withStartupClear = (lines: string[]): string[] =>
220
+ startupClearLines.length ? [...startupClearLines, ...lines] : lines;
221
+ if (!visible) {
222
+ stopAnimation();
223
+ clearNativeWidget(currentCtx);
224
+ lastSignature = "hidden";
225
+ return;
226
+ }
227
+ if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
228
+ clearWidgetTimer = undefined;
229
+ const generation = ++renderGeneration;
230
+ const pet = selectedPetId ? loadPet(selectedPetId) : undefined;
231
+ if (!pet) {
232
+ const lines = defaultLines();
233
+ const signature = lines.join("\n");
234
+ if (signature === lastSignature && !startupClearLines.length) return;
235
+ lastSignature = signature;
236
+ currentCtx.ui.setWidget("pi-sprite", formatTextSpriteLines(withStartupClear(lines), renderOptions()), {
237
+ placement: "belowEditor",
238
+ });
239
+ return;
240
+ }
241
+ const spritePath = pet.manifest.sprites[state] ?? pet.manifest.sprites.idle;
242
+ lastSignature = `loading:${pet.id}:${state}:${spritePath ?? ""}`;
243
+ if (supportsNativeSpriteImages()) {
244
+ currentCtx.ui.setWidget("pi-sprite", formatNativeSpritePlaceholderLines(startupClearLines, renderOptions()), {
245
+ placement: "belowEditor",
246
+ });
247
+ } else {
248
+ const loadingLines = [` ◕‿◕ ${pet.manifest.name}`];
249
+ if (label) loadingLines.push(`pi-sprite · loading ${state}${spritePath ? ` · ${basename(spritePath)}` : ""}`);
250
+ currentCtx.ui.setWidget("pi-sprite", formatTextSpriteLines(withStartupClear(loadingLines), renderOptions()), {
251
+ placement: "belowEditor",
252
+ });
253
+ }
254
+ void renderSpriteAnimation(pet, state, renderOptions()).then((animation) => {
255
+ if (generation !== renderGeneration || !visible || ctx !== currentCtx) return;
256
+ stopAnimation();
257
+ frameIndex = 0;
258
+ const applyFrame = () => {
259
+ const frame = animation.frames[frameIndex % animation.frames.length]!;
260
+ const nativeFrames = animation.frames.map((candidate) => candidate.native);
261
+ const hasNativeFrames = nativeFrames.every(Boolean);
262
+ const activeNativeImageIds = currentNativeImageIds(animation.frames.length);
263
+ const nativeMode = usesKittyPlaceholderSpriteImages()
264
+ ? "placeholder"
265
+ : supportsNativeSpriteImages()
266
+ ? "native"
267
+ : "ansi";
268
+ const frameSignature = `${animation.signature}:${frameIndex % animation.frames.length}:${nativeMode}:${size}:${label}:${align}`;
269
+ if (frameSignature === lastSignature) return;
270
+ lastSignature = frameSignature;
271
+ if (hasNativeFrames && usesKittyPlaceholderSpriteImages()) {
272
+ previousNativeFrameImageId = undefined;
273
+ currentCtx.ui.setWidget(
274
+ "pi-sprite",
275
+ () =>
276
+ buildKittyPlaceholderSpriteWidget(
277
+ nativeFrames as NonNullable<(typeof nativeFrames)[number]>[],
278
+ frameIndex % animation.frames.length,
279
+ `pi-sprite · ${state} · ${pet.manifest.name}`,
280
+ activeNativeImageIds,
281
+ renderOptions(),
282
+ ),
283
+ { placement: "belowEditor" },
284
+ );
285
+ } else if (frame.native && supportsNativeSpriteImages()) {
286
+ const frameImageId = activeNativeImageIds[frameIndex % activeNativeImageIds.length]!;
287
+ const previousImageId = previousNativeFrameImageId;
288
+ previousNativeFrameImageId = frameImageId;
289
+ currentCtx.ui.setWidget(
290
+ "pi-sprite",
291
+ () =>
292
+ buildNativeSpriteWidget(
293
+ frame.native!,
294
+ `pi-sprite · ${state} · ${pet.manifest.name}`,
295
+ frameImageId,
296
+ renderOptions(),
297
+ previousImageId,
298
+ ),
299
+ { placement: "belowEditor" },
300
+ );
301
+ } else {
302
+ currentCtx.ui.setWidget("pi-sprite", formatTextSpriteLines(frame.lines, renderOptions()), {
303
+ placement: "belowEditor",
304
+ });
305
+ }
306
+ };
307
+ applyFrame();
308
+ if (animation.frames.length > 1) {
309
+ animationTimer = setInterval(() => {
310
+ if (!visible || ctx !== currentCtx) return stopAnimation();
311
+ frameIndex++;
312
+ applyFrame();
313
+ }, 400);
314
+ }
315
+ });
316
+ }
317
+
318
+ function persist(): void {
319
+ saveSaved({
320
+ selectedPetId,
321
+ visible,
322
+ size,
323
+ label,
324
+ align,
325
+ turnStatusEnabled,
326
+ turnStatusConfigured,
327
+ liveStatusEnabled,
328
+ liveStatusConfigured,
329
+ });
330
+ }
331
+
332
+ function activatePet(id: string): void {
333
+ if (!loadPet(id)) throw new Error(`Unknown pet ${id}. Try /pet list.`);
334
+ selectedPetId = id;
335
+ visible = true;
336
+ persist();
337
+ lastSignature = "";
338
+ render();
339
+ }
340
+
341
+ async function importPetUrl(urlText: string) {
342
+ const bytes = await downloadToBuffer(urlText);
343
+ const tmp = join(spriteHome(), `import-${Date.now()}.zip`);
344
+ mkdirSync(spriteHome(), { recursive: true });
345
+ writeFileSync(tmp, bytes);
346
+ try {
347
+ return importPetZip(tmp);
348
+ } finally {
349
+ rmSync(tmp, { force: true });
350
+ }
351
+ }
352
+
353
+ return {
354
+ async start(nextCtx: ExtensionContext) {
355
+ if (ctx && ctx !== nextCtx) {
356
+ if (resetTimer) clearTimeout(resetTimer);
357
+ resetTimer = undefined;
358
+ stopAnimation();
359
+ if (ctx.hasUI) {
360
+ ctx.ui.setStatus("pi-sprite", undefined);
361
+ clearNativeWidget(ctx, { requireCurrentContext: false, trackTimer: false });
362
+ }
363
+ lastSignature = "";
364
+ }
365
+ ctx = nextCtx;
366
+ clearedStaleNativeImages = false;
367
+ const saved = loadSaved();
368
+ selectedPetId = saved.selectedPetId ?? selectedPetId;
369
+ visible = saved.visible ?? true;
370
+ size = saved.size ?? size;
371
+ label = saved.label ?? label;
372
+ align = saved.align ?? align;
373
+ turnStatusConfigured = saved.turnStatusConfigured ?? false;
374
+ turnStatusEnabled = turnStatusConfigured
375
+ ? (saved.turnStatusEnabled ?? DEFAULT_TURN_STATUS_ENABLED)
376
+ : DEFAULT_TURN_STATUS_ENABLED;
377
+ liveStatusConfigured = saved.liveStatusConfigured ?? false;
378
+ liveStatusEnabled = liveStatusConfigured
379
+ ? (saved.liveStatusEnabled ?? DEFAULT_LIVE_STATUS_ENABLED)
380
+ : DEFAULT_LIVE_STATUS_ENABLED;
381
+ state = "idle";
382
+ render();
383
+ },
384
+ setState(next: SpriteState, options: { resetMs?: number } = {}) {
385
+ if (resetTimer) clearTimeout(resetTimer);
386
+ if (next !== state) frameIndex = 0;
387
+ state = next;
388
+ render();
389
+ if (options.resetMs) resetTimer = setTimeout(() => this.setState("idle"), options.resetMs);
390
+ },
391
+ setBtwStatus(status: ActivityStatus, count = activity.btwCount) {
392
+ activity = { ...activity, btwStatus: status, btwCount: count };
393
+ updateFooter();
394
+ },
395
+ setRecapStatus(status: ActivityStatus) {
396
+ activity = { ...activity, recapStatus: status };
397
+ updateFooter();
398
+ },
399
+ isTurnStatusEnabled() {
400
+ return turnStatusEnabled;
401
+ },
402
+ isLiveStatusEnabled() {
403
+ return liveStatusEnabled;
404
+ },
405
+ clearTurnStatus() {
406
+ turnStatus = undefined;
407
+ updateFooter();
408
+ },
409
+ setTurnStatusPending() {
410
+ if (!turnStatusEnabled) return;
411
+ turnStatus = "pending";
412
+ updateFooter();
413
+ },
414
+ setTurnStatus(status: TurnStatus | undefined) {
415
+ if (!turnStatusEnabled) return;
416
+ liveStatusGeneration++;
417
+ liveStatus = undefined;
418
+ turnStatus = status;
419
+ updateFooter();
420
+ },
421
+ clearLiveStatus() {
422
+ liveStatusGeneration++;
423
+ liveStatus = undefined;
424
+ updateFooter();
425
+ },
426
+ setLiveStatusPending() {
427
+ if (!liveStatusEnabled) return undefined;
428
+ liveStatusGeneration++;
429
+ liveStatus = "pending";
430
+ updateFooter();
431
+ return liveStatusGeneration;
432
+ },
433
+ setLiveStatus(status: LiveTurnStatus | undefined, generation = liveStatusGeneration) {
434
+ if (!liveStatusEnabled || generation !== liveStatusGeneration) return;
435
+ liveStatus = status;
436
+ updateFooter();
437
+ },
438
+ getBubblePlacement() {
439
+ return bubblePlacement();
440
+ },
441
+ getSpriteName() {
442
+ return selectedName();
443
+ },
444
+ getSpritePersonality() {
445
+ return selectedPersonality();
446
+ },
447
+ shutdown() {
448
+ if (resetTimer) clearTimeout(resetTimer);
449
+ if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
450
+ resetTimer = undefined;
451
+ clearWidgetTimer = undefined;
452
+ stopAnimation();
453
+ if (ctx?.hasUI) {
454
+ ctx.ui.setStatus("pi-sprite", undefined);
455
+ clearNativeWidget(ctx, { removeAfter: false });
456
+ }
457
+ persist();
458
+ },
459
+ setCommandContext(nextCtx: ExtensionContext) {
460
+ ctx = nextCtx;
461
+ },
462
+ statusText() {
463
+ return `pi-sprite: ${visible ? "shown" : "hidden"}; pet=${selectedName()}; state=${state}; size=${size}; label=${label ? "on" : "off"}; align=${align}; turn-status=${turnStatusEnabled ? "on" : "off"}; live-status=${liveStatusEnabled ? "on" : "off"}${turnStatus && turnStatus !== "pending" ? `; ${formatTurnStatusFooter(turnStatus)}` : ""}${liveStatus && liveStatus !== "pending" ? `; ${formatLiveStatusFooter(liveStatus)}` : ""}`;
464
+ },
465
+ selectPet(id: string) {
466
+ activatePet(id);
467
+ },
468
+ importPetUrl,
469
+ show() {
470
+ visible = true;
471
+ persist();
472
+ lastSignature = "";
473
+ render();
474
+ },
475
+ hide() {
476
+ visible = false;
477
+ persist();
478
+ render();
479
+ },
480
+ setSize(nextSize: SpriteSize) {
481
+ size = nextSize;
482
+ persist();
483
+ lastSignature = "";
484
+ render();
485
+ },
486
+ setLabel(nextLabel: boolean) {
487
+ label = nextLabel;
488
+ persist();
489
+ lastSignature = "";
490
+ render();
491
+ },
492
+ setAlign(nextAlign: SpriteAlign) {
493
+ align = nextAlign;
494
+ persist();
495
+ lastSignature = "";
496
+ render();
497
+ },
498
+ setTurnStatusEnabled(enabled: boolean) {
499
+ turnStatusConfigured = true;
500
+ turnStatusEnabled = enabled;
501
+ if (!turnStatusEnabled) turnStatus = undefined;
502
+ persist();
503
+ updateFooter();
504
+ },
505
+ setLiveStatusEnabled(enabled: boolean) {
506
+ liveStatusConfigured = true;
507
+ liveStatusEnabled = enabled;
508
+ liveStatusGeneration++;
509
+ if (!liveStatusEnabled) liveStatus = undefined;
510
+ persist();
511
+ updateFooter();
512
+ },
513
+ clearNative(commandCtx: ExtensionContext) {
514
+ stopAnimation();
515
+ renderGeneration++;
516
+ const clearLines = [...clearTrackedNativeSpriteImages(), ...clearAllNativeSpriteImages()];
517
+ if (clearLines.length) commandCtx.ui.setWidget("pi-sprite", clearLines, { placement: "belowEditor" });
518
+ lastSignature = "";
519
+ },
520
+ registerCommands(pi: ExtensionAPI) {
521
+ registerSpriteCommands(pi, this as SpriteCommandRuntime);
522
+ },
523
+ };
524
+ }
@@ -0,0 +1,112 @@
1
+ export type TurnStatusState = "done" | "followup" | "blocked";
2
+
3
+ export interface TurnStatus {
4
+ state: TurnStatusState;
5
+ label: string;
6
+ detail?: string;
7
+ actions?: string[];
8
+ }
9
+
10
+ const STATE_EMOJI: Record<TurnStatusState, string> = {
11
+ done: "🟢",
12
+ followup: "🟡",
13
+ blocked: "🔴",
14
+ };
15
+
16
+ export function emojiForTurnStatus(state: TurnStatusState): string {
17
+ return STATE_EMOJI[state];
18
+ }
19
+
20
+ function trimToChars(text: string, maxChars: number): string {
21
+ const compact = text.replace(/\s+/gu, " ").trim();
22
+ if (compact.length <= maxChars) return compact;
23
+ return `${compact.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
24
+ }
25
+
26
+ export function formatTurnStatusFooter(status: TurnStatus, maxLabelChars = 44): string {
27
+ return `${emojiForTurnStatus(status.state)} ${trimToChars(status.label, maxLabelChars)} ✦`;
28
+ }
29
+
30
+ function extractJsonObject(text: string): string | undefined {
31
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/iu.exec(text);
32
+ const candidate = fenced?.[1] ?? text;
33
+ const start = candidate.indexOf("{");
34
+ const end = candidate.lastIndexOf("}");
35
+ if (start === -1 || end === -1 || end <= start) return undefined;
36
+ return candidate.slice(start, end + 1);
37
+ }
38
+
39
+ export function parseTurnStatusResponse(text: string): TurnStatus | undefined {
40
+ const json = extractJsonObject(text);
41
+ if (!json) return undefined;
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(json);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ if (!parsed || typeof parsed !== "object") return undefined;
49
+ const value = parsed as { state?: unknown; label?: unknown; detail?: unknown; actions?: unknown };
50
+ if (value.state !== "done" && value.state !== "followup" && value.state !== "blocked") return undefined;
51
+ if (typeof value.label !== "string" || !value.label.trim()) return undefined;
52
+ const actions = Array.isArray(value.actions)
53
+ ? value.actions
54
+ .filter((action): action is string => typeof action === "string" && Boolean(action.trim()))
55
+ .slice(0, 3)
56
+ : undefined;
57
+ return {
58
+ state: value.state,
59
+ label: trimToChars(value.label, 80),
60
+ detail: typeof value.detail === "string" && value.detail.trim() ? trimToChars(value.detail, 200) : undefined,
61
+ actions,
62
+ };
63
+ }
64
+
65
+ export function extractTextContent(content: unknown): string {
66
+ if (typeof content === "string") return content;
67
+ if (!Array.isArray(content)) return "";
68
+ return content
69
+ .map((part) => {
70
+ if (!part || typeof part !== "object") return "";
71
+ const typed = part as { type?: string; text?: string };
72
+ return typed.type === "text" && typeof typed.text === "string" ? typed.text : "";
73
+ })
74
+ .filter(Boolean)
75
+ .join("\n");
76
+ }
77
+
78
+ function branchMessage(entry: unknown): { role: string; text: string } | undefined {
79
+ if (!entry || typeof entry !== "object") return undefined;
80
+ const typed = entry as { type?: string; customType?: string; message?: { role?: string; content?: unknown } };
81
+ if (typed.type === "custom" || typed.customType) return undefined;
82
+ if (typed.type !== "message") return undefined;
83
+ const message = typed.message;
84
+ if (!message) return undefined;
85
+ const role = message.role;
86
+ if (role !== "user" && role !== "assistant") return undefined;
87
+ const text = extractTextContent(message.content).trim();
88
+ if (!text) return undefined;
89
+ return { role, text };
90
+ }
91
+
92
+ export function recentConversationForTurnStatus(
93
+ entries: Iterable<unknown>,
94
+ options: { maxMessages?: number; maxMessageChars?: number; maxTotalChars?: number } = {},
95
+ ): string {
96
+ const maxMessages = options.maxMessages ?? 10;
97
+ const maxMessageChars = options.maxMessageChars ?? 1500;
98
+ const maxTotalChars = options.maxTotalChars ?? 12_000;
99
+ const messages = Array.from(entries).flatMap((entry) => {
100
+ const message = branchMessage(entry);
101
+ return message ? [message] : [];
102
+ });
103
+ const selected: string[] = [];
104
+ let total = 0;
105
+ for (const message of messages.slice(-maxMessages).reverse()) {
106
+ const line = `${message.role}: ${trimToChars(message.text, maxMessageChars)}`;
107
+ if (total + line.length > maxTotalChars && selected.length > 0) break;
108
+ selected.unshift(trimToChars(line, maxTotalChars));
109
+ total += line.length;
110
+ }
111
+ return selected.join("\n\n");
112
+ }