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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +353 -0
- package/examples/README.md +15 -0
- package/examples/custom-pets/wumpus-template/README.md +21 -0
- package/examples/custom-pets/wumpus-template/pet.json +14 -0
- package/examples/petdex-downloads/.gitkeep +0 -0
- package/extensions/index.ts +104 -0
- package/package.json +77 -0
- package/skills/pi-sprite-authoring/SKILL.md +330 -0
- package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
- package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
- package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
- package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
- package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
- package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
- package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
- package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
- package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
- package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
- package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
- package/src/agent/session-entries.ts +28 -0
- package/src/agent/side-completion.ts +94 -0
- package/src/agent/side-session-text.ts +20 -0
- package/src/agent/side-session-types.ts +12 -0
- package/src/agent/side-session.ts +107 -0
- package/src/btw/completion.ts +15 -0
- package/src/btw/format.ts +30 -0
- package/src/btw/index.ts +306 -0
- package/src/btw/prompt.ts +35 -0
- package/src/btw/recap.ts +56 -0
- package/src/btw/session.ts +37 -0
- package/src/btw/thread-store.ts +37 -0
- package/src/context/index.ts +343 -0
- package/src/recap/conversation.ts +22 -0
- package/src/recap/direct.ts +15 -0
- package/src/recap/format.ts +25 -0
- package/src/recap/generation.ts +58 -0
- package/src/recap/index.ts +86 -0
- package/src/sprite/commands.ts +205 -0
- package/src/sprite/download.ts +75 -0
- package/src/sprite/kitty-placeholder.ts +441 -0
- package/src/sprite/live-status-format.ts +67 -0
- package/src/sprite/live-status.ts +48 -0
- package/src/sprite/loader.ts +134 -0
- package/src/sprite/manifest.ts +87 -0
- package/src/sprite/paths.ts +8 -0
- package/src/sprite/petdex.ts +133 -0
- package/src/sprite/renderer.ts +491 -0
- package/src/sprite/runtime.ts +524 -0
- package/src/sprite/turn-status-format.ts +112 -0
- package/src/sprite/turn-status.ts +88 -0
- package/src/ui/overlay.ts +308 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { NativeSpriteFrame, SpriteAlign, SpriteRenderOptions } from "./renderer.ts";
|
|
3
|
+
|
|
4
|
+
const PLACEHOLDER = "\u{10EEEE}";
|
|
5
|
+
const RESET_FG_AND_UNDERLINE = "\u001b[39;59m";
|
|
6
|
+
const TMUX_PASSTHROUGH_PREFIX = "\u001bPtmux;";
|
|
7
|
+
const TMUX_PASSTHROUGH_SUFFIX = "\u001b\\";
|
|
8
|
+
const ROW_COLUMN_DIACRITICS = [
|
|
9
|
+
"\u{305}",
|
|
10
|
+
"\u{30D}",
|
|
11
|
+
"\u{30E}",
|
|
12
|
+
"\u{310}",
|
|
13
|
+
"\u{312}",
|
|
14
|
+
"\u{33D}",
|
|
15
|
+
"\u{33E}",
|
|
16
|
+
"\u{33F}",
|
|
17
|
+
"\u{346}",
|
|
18
|
+
"\u{34A}",
|
|
19
|
+
"\u{34B}",
|
|
20
|
+
"\u{34C}",
|
|
21
|
+
"\u{350}",
|
|
22
|
+
"\u{351}",
|
|
23
|
+
"\u{352}",
|
|
24
|
+
"\u{357}",
|
|
25
|
+
"\u{35B}",
|
|
26
|
+
"\u{363}",
|
|
27
|
+
"\u{364}",
|
|
28
|
+
"\u{365}",
|
|
29
|
+
"\u{366}",
|
|
30
|
+
"\u{367}",
|
|
31
|
+
"\u{368}",
|
|
32
|
+
"\u{369}",
|
|
33
|
+
"\u{36A}",
|
|
34
|
+
"\u{36B}",
|
|
35
|
+
"\u{36C}",
|
|
36
|
+
"\u{36D}",
|
|
37
|
+
"\u{36E}",
|
|
38
|
+
"\u{36F}",
|
|
39
|
+
"\u{483}",
|
|
40
|
+
"\u{484}",
|
|
41
|
+
"\u{485}",
|
|
42
|
+
"\u{486}",
|
|
43
|
+
"\u{487}",
|
|
44
|
+
"\u{592}",
|
|
45
|
+
"\u{593}",
|
|
46
|
+
"\u{594}",
|
|
47
|
+
"\u{595}",
|
|
48
|
+
"\u{597}",
|
|
49
|
+
"\u{598}",
|
|
50
|
+
"\u{599}",
|
|
51
|
+
"\u{59C}",
|
|
52
|
+
"\u{59D}",
|
|
53
|
+
"\u{59E}",
|
|
54
|
+
"\u{59F}",
|
|
55
|
+
"\u{5A0}",
|
|
56
|
+
"\u{5A1}",
|
|
57
|
+
"\u{5A8}",
|
|
58
|
+
"\u{5A9}",
|
|
59
|
+
"\u{5AB}",
|
|
60
|
+
"\u{5AC}",
|
|
61
|
+
"\u{5AF}",
|
|
62
|
+
"\u{5C4}",
|
|
63
|
+
"\u{610}",
|
|
64
|
+
"\u{611}",
|
|
65
|
+
"\u{612}",
|
|
66
|
+
"\u{613}",
|
|
67
|
+
"\u{614}",
|
|
68
|
+
"\u{615}",
|
|
69
|
+
"\u{616}",
|
|
70
|
+
"\u{617}",
|
|
71
|
+
"\u{657}",
|
|
72
|
+
"\u{658}",
|
|
73
|
+
"\u{659}",
|
|
74
|
+
"\u{65A}",
|
|
75
|
+
"\u{65B}",
|
|
76
|
+
"\u{65D}",
|
|
77
|
+
"\u{65E}",
|
|
78
|
+
"\u{6D6}",
|
|
79
|
+
"\u{6D7}",
|
|
80
|
+
"\u{6D8}",
|
|
81
|
+
"\u{6D9}",
|
|
82
|
+
"\u{6DA}",
|
|
83
|
+
"\u{6DB}",
|
|
84
|
+
"\u{6DC}",
|
|
85
|
+
"\u{6DF}",
|
|
86
|
+
"\u{6E0}",
|
|
87
|
+
"\u{6E1}",
|
|
88
|
+
"\u{6E2}",
|
|
89
|
+
"\u{6E4}",
|
|
90
|
+
"\u{6E7}",
|
|
91
|
+
"\u{6E8}",
|
|
92
|
+
"\u{6EB}",
|
|
93
|
+
"\u{6EC}",
|
|
94
|
+
"\u{730}",
|
|
95
|
+
"\u{732}",
|
|
96
|
+
"\u{733}",
|
|
97
|
+
"\u{735}",
|
|
98
|
+
"\u{736}",
|
|
99
|
+
"\u{73A}",
|
|
100
|
+
"\u{73D}",
|
|
101
|
+
"\u{73F}",
|
|
102
|
+
"\u{740}",
|
|
103
|
+
"\u{741}",
|
|
104
|
+
"\u{743}",
|
|
105
|
+
"\u{745}",
|
|
106
|
+
"\u{747}",
|
|
107
|
+
"\u{749}",
|
|
108
|
+
"\u{74A}",
|
|
109
|
+
"\u{7EB}",
|
|
110
|
+
"\u{7EC}",
|
|
111
|
+
"\u{7ED}",
|
|
112
|
+
"\u{7EE}",
|
|
113
|
+
"\u{7EF}",
|
|
114
|
+
"\u{7F0}",
|
|
115
|
+
"\u{7F1}",
|
|
116
|
+
"\u{7F3}",
|
|
117
|
+
"\u{816}",
|
|
118
|
+
"\u{817}",
|
|
119
|
+
"\u{818}",
|
|
120
|
+
"\u{819}",
|
|
121
|
+
"\u{81B}",
|
|
122
|
+
"\u{81C}",
|
|
123
|
+
"\u{81D}",
|
|
124
|
+
"\u{81E}",
|
|
125
|
+
"\u{81F}",
|
|
126
|
+
"\u{820}",
|
|
127
|
+
"\u{821}",
|
|
128
|
+
"\u{822}",
|
|
129
|
+
"\u{823}",
|
|
130
|
+
"\u{825}",
|
|
131
|
+
"\u{826}",
|
|
132
|
+
"\u{827}",
|
|
133
|
+
"\u{829}",
|
|
134
|
+
"\u{82A}",
|
|
135
|
+
"\u{82B}",
|
|
136
|
+
"\u{82C}",
|
|
137
|
+
"\u{82D}",
|
|
138
|
+
"\u{951}",
|
|
139
|
+
"\u{953}",
|
|
140
|
+
"\u{954}",
|
|
141
|
+
"\u{F82}",
|
|
142
|
+
"\u{F83}",
|
|
143
|
+
"\u{F86}",
|
|
144
|
+
"\u{F87}",
|
|
145
|
+
"\u{135D}",
|
|
146
|
+
"\u{135E}",
|
|
147
|
+
"\u{135F}",
|
|
148
|
+
"\u{17DD}",
|
|
149
|
+
"\u{193A}",
|
|
150
|
+
"\u{1A17}",
|
|
151
|
+
"\u{1A75}",
|
|
152
|
+
"\u{1A76}",
|
|
153
|
+
"\u{1A77}",
|
|
154
|
+
"\u{1A78}",
|
|
155
|
+
"\u{1A79}",
|
|
156
|
+
"\u{1A7A}",
|
|
157
|
+
"\u{1A7B}",
|
|
158
|
+
"\u{1A7C}",
|
|
159
|
+
"\u{1B6B}",
|
|
160
|
+
"\u{1B6D}",
|
|
161
|
+
"\u{1B6E}",
|
|
162
|
+
"\u{1B6F}",
|
|
163
|
+
"\u{1B70}",
|
|
164
|
+
"\u{1B71}",
|
|
165
|
+
"\u{1B72}",
|
|
166
|
+
"\u{1B73}",
|
|
167
|
+
"\u{1CD0}",
|
|
168
|
+
"\u{1CD1}",
|
|
169
|
+
"\u{1CD2}",
|
|
170
|
+
"\u{1CDA}",
|
|
171
|
+
"\u{1CDB}",
|
|
172
|
+
"\u{1CE0}",
|
|
173
|
+
"\u{1DC0}",
|
|
174
|
+
"\u{1DC1}",
|
|
175
|
+
"\u{1DC3}",
|
|
176
|
+
"\u{1DC4}",
|
|
177
|
+
"\u{1DC5}",
|
|
178
|
+
"\u{1DC6}",
|
|
179
|
+
"\u{1DC7}",
|
|
180
|
+
"\u{1DC8}",
|
|
181
|
+
"\u{1DC9}",
|
|
182
|
+
"\u{1DCB}",
|
|
183
|
+
"\u{1DCC}",
|
|
184
|
+
"\u{1DD1}",
|
|
185
|
+
"\u{1DD2}",
|
|
186
|
+
"\u{1DD3}",
|
|
187
|
+
"\u{1DD4}",
|
|
188
|
+
"\u{1DD5}",
|
|
189
|
+
"\u{1DD6}",
|
|
190
|
+
"\u{1DD7}",
|
|
191
|
+
"\u{1DD8}",
|
|
192
|
+
"\u{1DD9}",
|
|
193
|
+
"\u{1DDA}",
|
|
194
|
+
"\u{1DDB}",
|
|
195
|
+
"\u{1DDC}",
|
|
196
|
+
"\u{1DDD}",
|
|
197
|
+
"\u{1DDE}",
|
|
198
|
+
"\u{1DDF}",
|
|
199
|
+
"\u{1DE0}",
|
|
200
|
+
"\u{1DE1}",
|
|
201
|
+
"\u{1DE2}",
|
|
202
|
+
"\u{1DE3}",
|
|
203
|
+
"\u{1DE4}",
|
|
204
|
+
"\u{1DE5}",
|
|
205
|
+
"\u{1DE6}",
|
|
206
|
+
"\u{1DFE}",
|
|
207
|
+
"\u{20D0}",
|
|
208
|
+
"\u{20D1}",
|
|
209
|
+
"\u{20D4}",
|
|
210
|
+
"\u{20D5}",
|
|
211
|
+
"\u{20D6}",
|
|
212
|
+
"\u{20D7}",
|
|
213
|
+
"\u{20DB}",
|
|
214
|
+
"\u{20DC}",
|
|
215
|
+
"\u{20E1}",
|
|
216
|
+
"\u{20E7}",
|
|
217
|
+
"\u{20E9}",
|
|
218
|
+
"\u{20F0}",
|
|
219
|
+
"\u{2CEF}",
|
|
220
|
+
"\u{2CF0}",
|
|
221
|
+
"\u{2CF1}",
|
|
222
|
+
"\u{2DE0}",
|
|
223
|
+
"\u{2DE1}",
|
|
224
|
+
"\u{2DE2}",
|
|
225
|
+
"\u{2DE3}",
|
|
226
|
+
"\u{2DE4}",
|
|
227
|
+
"\u{2DE5}",
|
|
228
|
+
"\u{2DE6}",
|
|
229
|
+
"\u{2DE7}",
|
|
230
|
+
"\u{2DE8}",
|
|
231
|
+
"\u{2DE9}",
|
|
232
|
+
"\u{2DEA}",
|
|
233
|
+
"\u{2DEB}",
|
|
234
|
+
"\u{2DEC}",
|
|
235
|
+
"\u{2DED}",
|
|
236
|
+
"\u{2DEE}",
|
|
237
|
+
"\u{2DEF}",
|
|
238
|
+
"\u{2DF0}",
|
|
239
|
+
"\u{2DF1}",
|
|
240
|
+
"\u{2DF2}",
|
|
241
|
+
"\u{2DF3}",
|
|
242
|
+
"\u{2DF4}",
|
|
243
|
+
"\u{2DF5}",
|
|
244
|
+
"\u{2DF6}",
|
|
245
|
+
"\u{2DF7}",
|
|
246
|
+
"\u{2DF8}",
|
|
247
|
+
"\u{2DF9}",
|
|
248
|
+
"\u{2DFA}",
|
|
249
|
+
"\u{2DFB}",
|
|
250
|
+
"\u{2DFC}",
|
|
251
|
+
"\u{2DFD}",
|
|
252
|
+
"\u{2DFE}",
|
|
253
|
+
"\u{2DFF}",
|
|
254
|
+
"\u{A66F}",
|
|
255
|
+
"\u{A67C}",
|
|
256
|
+
"\u{A67D}",
|
|
257
|
+
"\u{A6F0}",
|
|
258
|
+
"\u{A6F1}",
|
|
259
|
+
"\u{A8E0}",
|
|
260
|
+
"\u{A8E1}",
|
|
261
|
+
"\u{A8E2}",
|
|
262
|
+
"\u{A8E3}",
|
|
263
|
+
"\u{A8E4}",
|
|
264
|
+
"\u{A8E5}",
|
|
265
|
+
] as const;
|
|
266
|
+
|
|
267
|
+
export interface TerminalGraphicsSink {
|
|
268
|
+
write(sequence: string): void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const stdoutSink: TerminalGraphicsSink = {
|
|
272
|
+
write(sequence: string) {
|
|
273
|
+
process.stdout.write(sequence);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
let graphicsSink: TerminalGraphicsSink = stdoutSink;
|
|
278
|
+
const uploadedImages = new Map<number, string>();
|
|
279
|
+
const virtualPlacements = new Set<string>();
|
|
280
|
+
|
|
281
|
+
export function setTerminalGraphicsSinkForTests(sink: TerminalGraphicsSink | undefined): void {
|
|
282
|
+
graphicsSink = sink ?? stdoutSink;
|
|
283
|
+
uploadedImages.clear();
|
|
284
|
+
virtualPlacements.clear();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function clearKittyPlaceholderCaches(): void {
|
|
288
|
+
uploadedImages.clear();
|
|
289
|
+
virtualPlacements.clear();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function placeholderGlyph(): string {
|
|
293
|
+
return PLACEHOLDER;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function wrapTmuxPassthrough(sequence: string): string {
|
|
297
|
+
return `${TMUX_PASSTHROUGH_PREFIX}${sequence.replaceAll("\u001b", "\u001b\u001b")}${TMUX_PASSTHROUGH_SUFFIX}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isTmux(): boolean {
|
|
301
|
+
const term = process.env.TERM?.toLowerCase() ?? "";
|
|
302
|
+
return Boolean(process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function kittySequence(sequence: string): string {
|
|
306
|
+
return isTmux() ? wrapTmuxPassthrough(sequence) : sequence;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function paramSequence(params: string[], payload = ""): string {
|
|
310
|
+
return `\u001b_G${params.join(",")};${payload}\u001b\\`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function encodeKittyPlaceholderUpload(frame: NativeSpriteFrame, imageId: number): string {
|
|
314
|
+
const chunkSize = 4096;
|
|
315
|
+
const params = ["a=t", "f=100", `i=${imageId}`, "q=2"];
|
|
316
|
+
if (frame.base64.length <= chunkSize) return paramSequence(params, frame.base64);
|
|
317
|
+
const chunks: string[] = [];
|
|
318
|
+
for (let offset = 0; offset < frame.base64.length; offset += chunkSize) {
|
|
319
|
+
const chunk = frame.base64.slice(offset, offset + chunkSize);
|
|
320
|
+
const isFirst = offset === 0;
|
|
321
|
+
const isLast = offset + chunkSize >= frame.base64.length;
|
|
322
|
+
if (isFirst) chunks.push(paramSequence([...params, "m=1"], chunk));
|
|
323
|
+
else chunks.push(paramSequence([`m=${isLast ? 0 : 1}`], chunk));
|
|
324
|
+
}
|
|
325
|
+
return chunks.join("");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function encodeKittyVirtualPlacement(
|
|
329
|
+
imageId: number,
|
|
330
|
+
placementId: number,
|
|
331
|
+
columns: number,
|
|
332
|
+
rows: number,
|
|
333
|
+
): string {
|
|
334
|
+
return paramSequence(["a=p", "U=1", `i=${imageId}`, `p=${placementId}`, `c=${columns}`, `r=${rows}`, "q=2"]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function diacritic(index: number): string {
|
|
338
|
+
const value = ROW_COLUMN_DIACRITICS[index];
|
|
339
|
+
if (!value) throw new Error(`Kitty placeholder index ${index} exceeds supported diacritic table`);
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function foregroundForImageId(imageId: number): string {
|
|
344
|
+
const low = imageId & 0xffffff;
|
|
345
|
+
const r = (low >> 16) & 0xff;
|
|
346
|
+
const g = (low >> 8) & 0xff;
|
|
347
|
+
const b = low & 0xff;
|
|
348
|
+
return `\u001b[38;2;${r};${g};${b}m`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function placeholderPlacementId(imageId: number): number {
|
|
352
|
+
return imageId & 0xffffff || 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function underlineForPlacementId(placementId: number): string {
|
|
356
|
+
const low = placementId & 0xffffff;
|
|
357
|
+
const r = (low >> 16) & 0xff;
|
|
358
|
+
const g = (low >> 8) & 0xff;
|
|
359
|
+
const b = low & 0xff;
|
|
360
|
+
return `\u001b[58;2;${r};${g};${b}m`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function placeholderCell(row: number, column: number, imageId: number): string {
|
|
364
|
+
const highByte = (imageId >>> 24) & 0xff;
|
|
365
|
+
return `${PLACEHOLDER}${diacritic(row)}${diacritic(column)}${highByte ? diacritic(highByte) : ""}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function alignLine(line: string, width: number, align: SpriteAlign): string {
|
|
369
|
+
if (align !== "right") return line;
|
|
370
|
+
return `${" ".repeat(Math.max(0, width - visibleWidth(line) - 1))}${line}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function placeholderGridLines(
|
|
374
|
+
columns: number,
|
|
375
|
+
rows: number,
|
|
376
|
+
imageId: number,
|
|
377
|
+
placementId = placeholderPlacementId(imageId),
|
|
378
|
+
): string[] {
|
|
379
|
+
const prefix = `${foregroundForImageId(imageId)}${underlineForPlacementId(placementId)}`;
|
|
380
|
+
return Array.from(
|
|
381
|
+
{ length: rows },
|
|
382
|
+
(_, row) =>
|
|
383
|
+
`${prefix}${Array.from({ length: columns }, (_unused, column) => placeholderCell(row, column, imageId)).join("")}${RESET_FG_AND_UNDERLINE}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function uploadKittyPlaceholderFrame(
|
|
388
|
+
frame: NativeSpriteFrame,
|
|
389
|
+
imageId: number,
|
|
390
|
+
columns: number,
|
|
391
|
+
rows: number,
|
|
392
|
+
options: { forceUpload?: boolean } = {},
|
|
393
|
+
): void {
|
|
394
|
+
const imageKey = `${imageId}:${frame.base64}`;
|
|
395
|
+
if (options.forceUpload || uploadedImages.get(imageId) !== imageKey) {
|
|
396
|
+
graphicsSink.write(kittySequence(encodeKittyPlaceholderUpload(frame, imageId)));
|
|
397
|
+
uploadedImages.set(imageId, imageKey);
|
|
398
|
+
}
|
|
399
|
+
const placementId = placeholderPlacementId(imageId);
|
|
400
|
+
const placementKey = `${imageId}:${placementId}:${columns}:${rows}`;
|
|
401
|
+
if (!virtualPlacements.has(placementKey)) {
|
|
402
|
+
graphicsSink.write(kittySequence(encodeKittyVirtualPlacement(imageId, placementId, columns, rows)));
|
|
403
|
+
virtualPlacements.add(placementKey);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export class KittyPlaceholderSpriteWidget {
|
|
408
|
+
private activeFrameUploaded = false;
|
|
409
|
+
|
|
410
|
+
constructor(
|
|
411
|
+
private readonly frames: NativeSpriteFrame[],
|
|
412
|
+
private readonly activeFrameIndex: number,
|
|
413
|
+
private readonly imageIds: number[],
|
|
414
|
+
private readonly statusLine: string,
|
|
415
|
+
private readonly configuredSize: { columns: number; rows: number },
|
|
416
|
+
private readonly options: SpriteRenderOptions,
|
|
417
|
+
) {}
|
|
418
|
+
|
|
419
|
+
invalidate(): void {}
|
|
420
|
+
|
|
421
|
+
render(width: number): string[] {
|
|
422
|
+
const columns = Math.max(1, Math.min(width - 2, this.configuredSize.columns));
|
|
423
|
+
const rows = this.configuredSize.rows;
|
|
424
|
+
const activeIndex = this.activeFrameIndex % Math.max(1, this.frames.length);
|
|
425
|
+
const activeImageId = this.imageIds[activeIndex % this.imageIds.length] ?? this.imageIds[0] ?? 1;
|
|
426
|
+
const activeFrame = this.frames[activeIndex];
|
|
427
|
+
if (activeFrame && activeImageId && !this.activeFrameUploaded) {
|
|
428
|
+
uploadKittyPlaceholderFrame(activeFrame, activeImageId, columns, rows, { forceUpload: true });
|
|
429
|
+
this.activeFrameUploaded = true;
|
|
430
|
+
}
|
|
431
|
+
for (const [index, frame] of this.frames.entries()) {
|
|
432
|
+
const imageId = this.imageIds[index] ?? this.imageIds[0];
|
|
433
|
+
if (!imageId || index === activeIndex) continue;
|
|
434
|
+
uploadKittyPlaceholderFrame(frame, imageId, columns, rows);
|
|
435
|
+
}
|
|
436
|
+
const align = this.options.align ?? "left";
|
|
437
|
+
const lines = placeholderGridLines(columns, rows, activeImageId).map((line) => alignLine(line, width, align));
|
|
438
|
+
if (!this.options.label) return lines;
|
|
439
|
+
return [...lines, alignLine(this.statusLine, width, align)];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface LiveTurnStatus {
|
|
2
|
+
label: string;
|
|
3
|
+
detail?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function trimToChars(text: string, maxChars: number): string {
|
|
7
|
+
const compact = text.replace(/\s+/gu, " ").trim();
|
|
8
|
+
if (compact.length <= maxChars) return compact;
|
|
9
|
+
return `${compact.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function extractJsonObject(text: string): string | undefined {
|
|
13
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/iu.exec(text);
|
|
14
|
+
const candidate = fenced?.[1] ?? text;
|
|
15
|
+
const start = candidate.indexOf("{");
|
|
16
|
+
const end = candidate.lastIndexOf("}");
|
|
17
|
+
if (start === -1 || end === -1 || end <= start) return undefined;
|
|
18
|
+
return candidate.slice(start, end + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function claimsFinalOutcome(label: string): boolean {
|
|
22
|
+
return /\b(done|complete|completed|finished|fixed|verified|merged|ready|passed|passing)\b|\ball\s+done\b|\btests?\s+pass(?:ed|ing)?\b|\bpr\s+ready\b/iu.test(
|
|
23
|
+
label,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatLiveStatusFooter(status: LiveTurnStatus, maxLabelChars = 38): string {
|
|
28
|
+
return `🟣 ${trimToChars(status.label, maxLabelChars)}…`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseLiveStatusResponse(text: string): LiveTurnStatus | undefined {
|
|
32
|
+
const json = extractJsonObject(text);
|
|
33
|
+
if (!json) return undefined;
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(json);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
41
|
+
const value = parsed as { label?: unknown; detail?: unknown };
|
|
42
|
+
if (typeof value.label !== "string" || !value.label.trim()) return undefined;
|
|
43
|
+
if (claimsFinalOutcome(value.label)) return undefined;
|
|
44
|
+
return {
|
|
45
|
+
label: trimToChars(value.label, 72),
|
|
46
|
+
detail: typeof value.detail === "string" && value.detail.trim() ? trimToChars(value.detail, 160) : undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function promptForLiveStatus(recentConversation: string): string {
|
|
51
|
+
return [
|
|
52
|
+
"Summarize the current in-progress work of this Pi coding-agent turn for a compact sprite footer.",
|
|
53
|
+
"This status is provisional and may be shown while the agent is still working.",
|
|
54
|
+
"Do not claim the task is complete, merged, fixed, verified, or done.",
|
|
55
|
+
"Do not add personality, encouragement, jokes, or autonomous commentary.",
|
|
56
|
+
"Return JSON only with this shape:",
|
|
57
|
+
'{"label":"2-6 word current activity","detail":"optional one sentence"}',
|
|
58
|
+
"Guidelines:",
|
|
59
|
+
"- label should describe current activity, not final outcome.",
|
|
60
|
+
"- label should be concrete and footer-friendly, around 2-6 words.",
|
|
61
|
+
"- Good labels: editing renderer, running tests, investigating tmux cleanup, writing docs.",
|
|
62
|
+
"- Bad labels: all done, tests passed, PR ready, great progress.",
|
|
63
|
+
"",
|
|
64
|
+
"Recent conversation:",
|
|
65
|
+
recentConversation || "(No recent conversation available.)",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { completeWithApiKeyText } from "../agent/side-completion.ts";
|
|
3
|
+
import type { SideCompletionRequest, SideCompletionResult } from "../agent/side-session-types.ts";
|
|
4
|
+
import { type LiveTurnStatus, parseLiveStatusResponse, promptForLiveStatus } from "./live-status-format.ts";
|
|
5
|
+
import { recentConversationForTurnStatus } from "./turn-status-format.ts";
|
|
6
|
+
|
|
7
|
+
const LIVE_STATUS_MAX_TOKENS = 120;
|
|
8
|
+
const LIVE_STATUS_TIMEOUT_MS = 20_000;
|
|
9
|
+
|
|
10
|
+
type SideSessionAdapter = (ctx: ExtensionContext, request: SideCompletionRequest) => Promise<SideCompletionResult>;
|
|
11
|
+
type DirectCompletionAdapter = (ctx: ExtensionContext, prompt: string) => Promise<string | undefined>;
|
|
12
|
+
|
|
13
|
+
type LiveStatusAdapters = {
|
|
14
|
+
sideSession: SideSessionAdapter;
|
|
15
|
+
direct: DirectCompletionAdapter;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function directLiveStatusCompletion(ctx: ExtensionContext, prompt: string): Promise<string | undefined> {
|
|
19
|
+
const result = await completeWithApiKeyText(ctx, prompt, {
|
|
20
|
+
maxTokens: LIVE_STATUS_MAX_TOKENS,
|
|
21
|
+
timeoutMs: LIVE_STATUS_TIMEOUT_MS,
|
|
22
|
+
});
|
|
23
|
+
return result.ok ? result.text : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultAdapters: LiveStatusAdapters = {
|
|
27
|
+
sideSession: async (ctx, request) => {
|
|
28
|
+
const { completeWithSideSession } = await import("../agent/side-session.ts");
|
|
29
|
+
return await completeWithSideSession(ctx, request);
|
|
30
|
+
},
|
|
31
|
+
direct: directLiveStatusCompletion,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function classifyLiveTurnStatus(
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
adapters: LiveStatusAdapters = defaultAdapters,
|
|
37
|
+
): Promise<LiveTurnStatus | undefined> {
|
|
38
|
+
const recentConversation = recentConversationForTurnStatus(ctx.sessionManager.getBranch() as Iterable<unknown>);
|
|
39
|
+
const prompt = promptForLiveStatus(recentConversation);
|
|
40
|
+
const sideResult = await adapters.sideSession(ctx, {
|
|
41
|
+
prompt,
|
|
42
|
+
maxTokens: LIVE_STATUS_MAX_TOKENS,
|
|
43
|
+
timeoutMs: LIVE_STATUS_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
if (sideResult.ok) return parseLiveStatusResponse(sideResult.text);
|
|
46
|
+
const directText = await adapters.direct(ctx, prompt);
|
|
47
|
+
return directText ? parseLiveStatusResponse(directText) : undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname, isAbsolute, join, normalize, sep } from "node:path";
|
|
3
|
+
import AdmZip from "adm-zip";
|
|
4
|
+
import { normalizePetId, type PetManifest, parsePetManifest } from "./manifest.ts";
|
|
5
|
+
import { petsDir } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
export interface InstalledPet {
|
|
8
|
+
id: string;
|
|
9
|
+
dir: string;
|
|
10
|
+
manifest: PetManifest;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ALLOWED_IMPORT_EXTENSIONS = new Set([".json", ".png", ".webp", ".gif", ".jpg", ".jpeg", ".txt", ".md"]);
|
|
14
|
+
const MAX_IMPORT_FILE_SIZE = 5 * 1024 * 1024;
|
|
15
|
+
const MAX_IMPORT_TOTAL_SIZE = 25 * 1024 * 1024;
|
|
16
|
+
const MAX_IMPORT_FILES = 80;
|
|
17
|
+
|
|
18
|
+
export function ensurePetDirs(): void {
|
|
19
|
+
mkdirSync(petsDir(), { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadPet(id: string): InstalledPet | undefined {
|
|
23
|
+
const safeId = normalizePetId(id);
|
|
24
|
+
if (!safeId) return undefined;
|
|
25
|
+
const dir = join(petsDir(), safeId);
|
|
26
|
+
const manifestPath = join(dir, "pet.json");
|
|
27
|
+
if (!existsSync(manifestPath)) return undefined;
|
|
28
|
+
try {
|
|
29
|
+
const manifest = parsePetManifest(JSON.parse(readFileSync(manifestPath, "utf8")));
|
|
30
|
+
return { id: manifest.id, dir, manifest };
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listPets(): InstalledPet[] {
|
|
37
|
+
ensurePetDirs();
|
|
38
|
+
return readdirSync(petsDir(), { withFileTypes: true })
|
|
39
|
+
.filter((entry) => entry.isDirectory())
|
|
40
|
+
.map((entry) => loadPet(entry.name))
|
|
41
|
+
.filter((pet): pet is InstalledPet => Boolean(pet))
|
|
42
|
+
.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validateImportSource(path: string): void {
|
|
46
|
+
if (!existsSync(path) || !statSync(path).isDirectory())
|
|
47
|
+
throw new Error("/pet import currently expects a local folder");
|
|
48
|
+
if (!existsSync(join(path, "pet.json"))) throw new Error("pet folder must contain pet.json");
|
|
49
|
+
let total = 0;
|
|
50
|
+
let count = 0;
|
|
51
|
+
const walk = (dir: string) => {
|
|
52
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
const fullPath = join(dir, entry.name);
|
|
54
|
+
if (entry.isSymbolicLink()) throw new Error("pet imports may not contain symlinks");
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
walk(fullPath);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
count++;
|
|
60
|
+
if (count > MAX_IMPORT_FILES) throw new Error("pet import contains too many files");
|
|
61
|
+
const ext = extname(entry.name).toLowerCase();
|
|
62
|
+
if (!ALLOWED_IMPORT_EXTENSIONS.has(ext))
|
|
63
|
+
throw new Error(`pet import contains unsupported file type: ${entry.name}`);
|
|
64
|
+
const size = statSync(fullPath).size;
|
|
65
|
+
if (size > MAX_IMPORT_FILE_SIZE) throw new Error(`pet import file is too large: ${entry.name}`);
|
|
66
|
+
total += size;
|
|
67
|
+
if (total > MAX_IMPORT_TOTAL_SIZE) throw new Error("pet import is too large");
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function importPetFolder(sourcePath: string): InstalledPet {
|
|
74
|
+
validateImportSource(sourcePath);
|
|
75
|
+
const manifest = parsePetManifest(JSON.parse(readFileSync(join(sourcePath, "pet.json"), "utf8")));
|
|
76
|
+
const destination = join(petsDir(), manifest.id);
|
|
77
|
+
mkdirSync(petsDir(), { recursive: true });
|
|
78
|
+
const tempDestination = join(petsDir(), `.tmp-${manifest.id}-${Date.now()}`);
|
|
79
|
+
rmSync(tempDestination, { recursive: true, force: true });
|
|
80
|
+
cpSync(sourcePath, tempDestination, { recursive: true, verbatimSymlinks: false });
|
|
81
|
+
writeFileSync(join(tempDestination, "pet.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
82
|
+
rmSync(destination, { recursive: true, force: true });
|
|
83
|
+
cpSync(tempDestination, destination, { recursive: true });
|
|
84
|
+
rmSync(tempDestination, { recursive: true, force: true });
|
|
85
|
+
const installed = loadPet(manifest.id);
|
|
86
|
+
if (!installed) throw new Error(`Imported ${basename(sourcePath)}, but validation failed`);
|
|
87
|
+
return installed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findZipRoot(tmp: string): string {
|
|
91
|
+
if (existsSync(join(tmp, "pet.json"))) return tmp;
|
|
92
|
+
const child = readdirSync(tmp, { withFileTypes: true }).find(
|
|
93
|
+
(entry) => entry.isDirectory() && existsSync(join(tmp, entry.name, "pet.json")),
|
|
94
|
+
);
|
|
95
|
+
return child ? join(tmp, child.name) : tmp;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function importPetZip(zipPath: string): InstalledPet {
|
|
99
|
+
const zip = new AdmZip(zipPath);
|
|
100
|
+
const entries = zip.getEntries();
|
|
101
|
+
if (entries.length > MAX_IMPORT_FILES) throw new Error("pet zip contains too many files");
|
|
102
|
+
let total = 0;
|
|
103
|
+
const tmp = join(petsDir(), `.zip-${Date.now()}`);
|
|
104
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
105
|
+
mkdirSync(tmp, { recursive: true });
|
|
106
|
+
try {
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const normalized = normalize(entry.entryName);
|
|
109
|
+
if (
|
|
110
|
+
!normalized ||
|
|
111
|
+
normalized === "." ||
|
|
112
|
+
isAbsolute(normalized) ||
|
|
113
|
+
normalized.startsWith("..") ||
|
|
114
|
+
normalized.includes(`..${sep}`)
|
|
115
|
+
) {
|
|
116
|
+
throw new Error("pet zip contains path traversal");
|
|
117
|
+
}
|
|
118
|
+
if (entry.isDirectory) continue;
|
|
119
|
+
const ext = extname(normalized).toLowerCase();
|
|
120
|
+
if (!ALLOWED_IMPORT_EXTENSIONS.has(ext))
|
|
121
|
+
throw new Error(`pet zip contains unsupported file type: ${entry.entryName}`);
|
|
122
|
+
const data = entry.getData();
|
|
123
|
+
if (data.length > MAX_IMPORT_FILE_SIZE) throw new Error(`pet zip file is too large: ${entry.entryName}`);
|
|
124
|
+
total += data.length;
|
|
125
|
+
if (total > MAX_IMPORT_TOTAL_SIZE) throw new Error("pet zip is too large");
|
|
126
|
+
const destination = join(tmp, normalized);
|
|
127
|
+
mkdirSync(join(destination, ".."), { recursive: true });
|
|
128
|
+
writeFileSync(destination, data);
|
|
129
|
+
}
|
|
130
|
+
return importPetFolder(findZipRoot(tmp));
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|