@workweave/router 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Wooly's animation-only terminal component, extracted from Loom's default
3
+ * text renderer. The coaching reader, narration state, high-resolution image
4
+ * transport, and video-game dialogue box deliberately do not live in this
5
+ * extension.
6
+ */
7
+
8
+ import type { Component, TUI } from "@mariozechner/pi-tui";
9
+
10
+ type Rgb = readonly [red: number, green: number, blue: number];
11
+ type Pixel = Rgb | null;
12
+ type Canvas = Pixel[][];
13
+ type WoolyAnimation = "wave" | "jump" | "spin" | "retract";
14
+
15
+ type SpriteSpec = {
16
+ width: number;
17
+ pixelHeight: number;
18
+ bodyX: number;
19
+ bodyY: number;
20
+ bodyRadius: number;
21
+ legBottom: number;
22
+ compact: boolean;
23
+ };
24
+
25
+ const RESET = "\x1b[0m";
26
+ const WOOLY_ORANGE: Rgb = [235, 73, 28];
27
+ const WOOLY_BLACK: Rgb = [34, 29, 27];
28
+ const HORIZONTAL_SUBPIXELS = 2;
29
+ const QUADRANT_GLYPHS = [" ", "▘", "▝", "▀", "▖", "▌", "▞", "▛", "▗", "▚", "▐", "▜", "▄", "▙", "▟", "█"] as const;
30
+ const FULL_SPEC: SpriteSpec = {
31
+ width: 21,
32
+ pixelHeight: 14,
33
+ bodyX: 10,
34
+ bodyY: 5,
35
+ bodyRadius: 4.5,
36
+ legBottom: 13,
37
+ compact: false,
38
+ };
39
+ const COMPACT_SPEC: SpriteSpec = {
40
+ width: 15,
41
+ pixelHeight: 10,
42
+ bodyX: 7,
43
+ bodyY: 3.5,
44
+ bodyRadius: 3,
45
+ legBottom: 9,
46
+ compact: true,
47
+ };
48
+ const FRAME_COUNT = 8;
49
+ const ANIMATION_SEQUENCE: readonly WoolyAnimation[] = ["wave", "jump", "spin", "retract"];
50
+ const WAVE_LIFT = [0, 0.45, 1, 1, 1, 1, 0.55, 0.2] as const;
51
+ const WAVE_SWAY = [0, 0, -1, 1, -1, 1, 0, 0] as const;
52
+ const JUMP_OFFSETS = [0, 1, 1, 1, 1, 1, 1, 0] as const;
53
+ const JUMP_TUCK = [0, 0, 1, 1, 1, 1, 0, 0] as const;
54
+ const RETRACT_AMOUNT = [0, 0.35, 0.7, 1, 1, 0.7, 0.35, 0] as const;
55
+ const MIN_VISIBLE_WIDTH = 20;
56
+ const MIN_VISIBLE_ROWS = 18;
57
+ const MIN_FULL_WIDTH = 44;
58
+ const MIN_FULL_ROWS = 30;
59
+ const LEFT_PADDING = 2;
60
+
61
+ export const WOOLY_FRAME_INTERVAL_MS = 200;
62
+ export const WOOLY_ANIMATION_DELAY_MS = 5_000;
63
+
64
+ function foreground([red, green, blue]: Rgb): string {
65
+ return `\x1b[38;2;${red};${green};${blue}m`;
66
+ }
67
+
68
+ function background([red, green, blue]: Rgb): string {
69
+ return `\x1b[48;2;${red};${green};${blue}m`;
70
+ }
71
+
72
+ function colorsMatch(first: Rgb, second: Rgb): boolean {
73
+ return first[0] === second[0] && first[1] === second[1] && first[2] === second[2];
74
+ }
75
+
76
+ function averageColors(colors: Rgb[]): Rgb {
77
+ const totals = colors.reduce(
78
+ (sum, color) => [sum[0] + color[0], sum[1] + color[1], sum[2] + color[2]],
79
+ [0, 0, 0],
80
+ );
81
+ return [
82
+ Math.round(totals[0] / colors.length),
83
+ Math.round(totals[1] / colors.length),
84
+ Math.round(totals[2] / colors.length),
85
+ ];
86
+ }
87
+
88
+ function colorDistance(first: Rgb, second: Rgb): number {
89
+ return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + (first[2] - second[2]) ** 2;
90
+ }
91
+
92
+ function renderQuadrantBlock(topLeft: Pixel, topRight: Pixel, bottomLeft: Pixel, bottomRight: Pixel): string {
93
+ const pixels = [topLeft, topRight, bottomLeft, bottomRight] as const;
94
+ let occupiedMask = 0;
95
+ let featureMask = 0;
96
+ const yarnColors: Rgb[] = [];
97
+ for (let index = 0; index < pixels.length; index++) {
98
+ const pixel = pixels[index];
99
+ if (pixel === null) continue;
100
+ occupiedMask |= 1 << index;
101
+ if (colorsMatch(pixel, WOOLY_BLACK)) featureMask |= 1 << index;
102
+ else yarnColors.push(pixel);
103
+ }
104
+
105
+ if (occupiedMask === 0) return " ";
106
+ if (occupiedMask !== 0b1111) {
107
+ if (featureMask !== 0) return `${foreground(WOOLY_BLACK)}${QUADRANT_GLYPHS[featureMask]}${RESET}`;
108
+ return `${foreground(averageColors(yarnColors))}${QUADRANT_GLYPHS[occupiedMask]}${RESET}`;
109
+ }
110
+ if (featureMask !== 0) {
111
+ if (featureMask === 0b1111) return `${foreground(WOOLY_BLACK)}█${RESET}`;
112
+ return `${foreground(WOOLY_BLACK)}${background(averageColors(yarnColors))}${QUADRANT_GLYPHS[featureMask]}${RESET}`;
113
+ }
114
+
115
+ let darkest = yarnColors[0]!;
116
+ let lightest = yarnColors[0]!;
117
+ for (const color of yarnColors.slice(1)) {
118
+ const luminance = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722;
119
+ const darkestLuminance = darkest[0] * 0.2126 + darkest[1] * 0.7152 + darkest[2] * 0.0722;
120
+ const lightestLuminance = lightest[0] * 0.2126 + lightest[1] * 0.7152 + lightest[2] * 0.0722;
121
+ if (luminance < darkestLuminance) darkest = color;
122
+ if (luminance > lightestLuminance) lightest = color;
123
+ }
124
+ if (colorsMatch(darkest, lightest)) return `${foreground(darkest)}█${RESET}`;
125
+
126
+ let darkMask = 0;
127
+ for (let index = 0; index < pixels.length; index++) {
128
+ const pixel = pixels[index]!;
129
+ if (colorDistance(pixel, darkest) <= colorDistance(pixel, lightest)) darkMask |= 1 << index;
130
+ }
131
+ if (darkMask === 0 || darkMask === 0b1111) return `${foreground(averageColors(yarnColors))}█${RESET}`;
132
+ return `${foreground(darkest)}${background(lightest)}${QUADRANT_GLYPHS[darkMask]}${RESET}`;
133
+ }
134
+
135
+ function createCanvas(spec: SpriteSpec): Canvas {
136
+ return Array.from({ length: spec.pixelHeight }, () => Array<Pixel>(spec.width * HORIZONTAL_SUBPIXELS).fill(null));
137
+ }
138
+
139
+ function scaleX(value: number): number {
140
+ return value * HORIZONTAL_SUBPIXELS;
141
+ }
142
+
143
+ function interpolate(from: number, to: number, amount: number): number {
144
+ return from + (to - from) * amount;
145
+ }
146
+
147
+ function paint(canvas: Canvas, x: number, y: number, color: Rgb): void {
148
+ if (y < 0 || y >= canvas.length || x < 0 || x >= canvas[0]!.length) return;
149
+ canvas[y]![x] = color;
150
+ }
151
+
152
+ function drawLine(canvas: Canvas, fromX: number, fromY: number, toX: number, toY: number, color: Rgb): void {
153
+ let x = Math.round(fromX);
154
+ let y = Math.round(fromY);
155
+ const endX = Math.round(toX);
156
+ const endY = Math.round(toY);
157
+ const deltaX = Math.abs(endX - x);
158
+ const stepX = x < endX ? 1 : -1;
159
+ const deltaY = -Math.abs(endY - y);
160
+ const stepY = y < endY ? 1 : -1;
161
+ let error = deltaX + deltaY;
162
+
163
+ while (true) {
164
+ paint(canvas, x, y, color);
165
+ if (x === endX && y === endY) return;
166
+ const doubledError = error * 2;
167
+ if (doubledError >= deltaY) {
168
+ error += deltaY;
169
+ x += stepX;
170
+ }
171
+ if (doubledError <= deltaX) {
172
+ error += deltaX;
173
+ y += stepY;
174
+ }
175
+ }
176
+ }
177
+
178
+ function clampChannel(value: number): number {
179
+ return Math.max(0, Math.min(255, Math.round(value)));
180
+ }
181
+
182
+ function shadeOrange(amount: number): Rgb {
183
+ return [
184
+ clampChannel(WOOLY_ORANGE[0] + amount),
185
+ clampChannel(WOOLY_ORANGE[1] + amount * 0.62),
186
+ clampChannel(WOOLY_ORANGE[2] + amount * 0.28),
187
+ ];
188
+ }
189
+
190
+ function drawLimbs(
191
+ canvas: Canvas,
192
+ spec: SpriteSpec,
193
+ spinFrame: number,
194
+ waveFrame: number,
195
+ jumpFrame: number,
196
+ retractFrame: number,
197
+ ): void {
198
+ const angle = (spinFrame * Math.PI) / 4;
199
+ const turn = Math.sin(angle);
200
+ const bob = spinFrame % 2;
201
+ const radius = spec.bodyRadius;
202
+ const shoulderY = spec.bodyY;
203
+ const elbowY = spec.bodyY + radius * 0.35;
204
+ const handY = Math.min(spec.legBottom - 1, spec.bodyY + radius * 0.92 + bob);
205
+ const armReach = (spec.compact ? 2.1 : 3.4) - 1;
206
+ const sway = turn * (spec.compact ? 0.7 : 1.2);
207
+ const waveLift = WAVE_LIFT[waveFrame] ?? 0;
208
+ const waveSway = (WAVE_SWAY[waveFrame] ?? 0) * (spec.compact ? 0.45 : 0.75);
209
+ const retractAmount = RETRACT_AMOUNT[retractFrame] ?? 0;
210
+ if (retractAmount === 1) return;
211
+
212
+ const leftShoulderX = spec.bodyX - radius + 1;
213
+ const leftElbowX = interpolate(spec.bodyX - radius - 1, leftShoulderX, retractAmount);
214
+ const leftElbowY = interpolate(elbowY, shoulderY, retractAmount);
215
+ const leftHandX = interpolate(spec.bodyX - radius - armReach + sway, leftShoulderX, retractAmount);
216
+ const leftHandY = interpolate(handY, shoulderY, retractAmount);
217
+ drawLine(canvas, scaleX(leftShoulderX), shoulderY, scaleX(leftElbowX), leftElbowY, WOOLY_BLACK);
218
+ drawLine(canvas, scaleX(leftElbowX), leftElbowY, scaleX(leftHandX), leftHandY, WOOLY_BLACK);
219
+
220
+ const normalRightElbowX = spec.bodyX + radius + 1;
221
+ const normalRightHandX = spec.bodyX + radius + armReach + sway;
222
+ const raisedRightElbowX = spec.bodyX + radius + 1;
223
+ const raisedRightElbowY = spec.bodyY - radius * 0.05;
224
+ const raisedRightHandX = spec.bodyX + radius + armReach * 0.65 + waveSway;
225
+ const raisedRightHandY = spec.bodyY - radius * 0.82;
226
+ const rightShoulderX = spec.bodyX + radius - 1;
227
+ const animatedRightElbowX = interpolate(normalRightElbowX, raisedRightElbowX, waveLift);
228
+ const animatedRightElbowY = interpolate(elbowY, raisedRightElbowY, waveLift);
229
+ const animatedRightHandX = interpolate(normalRightHandX, raisedRightHandX, waveLift);
230
+ const animatedRightHandY = interpolate(handY, raisedRightHandY, waveLift);
231
+ const rightElbowX = interpolate(animatedRightElbowX, rightShoulderX, retractAmount);
232
+ const rightElbowY = interpolate(animatedRightElbowY, shoulderY, retractAmount);
233
+ const rightHandX = interpolate(animatedRightHandX, rightShoulderX, retractAmount);
234
+ const rightHandY = interpolate(animatedRightHandY, shoulderY, retractAmount);
235
+ drawLine(canvas, scaleX(rightShoulderX), shoulderY, scaleX(rightElbowX), rightElbowY, WOOLY_BLACK);
236
+ drawLine(canvas, scaleX(rightElbowX), rightElbowY, scaleX(rightHandX), rightHandY, WOOLY_BLACK);
237
+
238
+ const legTop = spec.bodyY + radius - 1;
239
+ const legBottom = interpolate(spec.legBottom - (JUMP_TUCK[jumpFrame] ?? 0), legTop, retractAmount);
240
+ const legSpread = spec.compact ? 1.45 : 2.35;
241
+ const legShift = turn * (spec.compact ? 0.45 : 0.8);
242
+ const leftFootX = scaleX(interpolate(spec.bodyX - legSpread + legShift, spec.bodyX, retractAmount));
243
+ const rightFootX = scaleX(interpolate(spec.bodyX + legSpread + legShift, spec.bodyX, retractAmount));
244
+ const footReach = HORIZONTAL_SUBPIXELS * (1 - retractAmount);
245
+ drawLine(canvas, leftFootX, legTop, leftFootX, legBottom, WOOLY_BLACK);
246
+ drawLine(canvas, rightFootX, legTop, rightFootX, legBottom, WOOLY_BLACK);
247
+ drawLine(canvas, leftFootX, legBottom, leftFootX - footReach, legBottom, WOOLY_BLACK);
248
+ drawLine(canvas, rightFootX, legBottom, rightFootX + footReach, legBottom, WOOLY_BLACK);
249
+ }
250
+
251
+ function drawYarnBody(canvas: Canvas, spec: SpriteSpec, frame: number): void {
252
+ const angle = (frame * Math.PI) / 4;
253
+ for (let y = 0; y < spec.pixelHeight; y++) {
254
+ for (let x = 0; x < spec.width * HORIZONTAL_SUBPIXELS; x++) {
255
+ const logicalX = x / HORIZONTAL_SUBPIXELS;
256
+ const normalizedX = (logicalX - spec.bodyX) / spec.bodyRadius;
257
+ const normalizedY = (y - spec.bodyY) / spec.bodyRadius;
258
+ const distanceSquared = normalizedX ** 2 + normalizedY ** 2;
259
+ if (distanceSquared > 1) continue;
260
+
261
+ const edgeShade = -23 * Math.max(0, Math.sqrt(distanceSquared) - 0.58);
262
+ const lightShade = -normalizedX * 13 - normalizedY * 10;
263
+ const yarnWave =
264
+ Math.sin(logicalX * 0.92 + y * 1.47 + angle * 1.8) +
265
+ Math.sin(logicalX * 1.73 - y * 0.61 - angle * 1.15) * 0.65;
266
+ const yarnShade = yarnWave > 0.9 ? 20 : yarnWave < -0.9 ? -18 : yarnWave * 5;
267
+ paint(canvas, x, y, shadeOrange(edgeShade + lightShade + yarnShade));
268
+ }
269
+ }
270
+ }
271
+
272
+ function drawEye(canvas: Canvas, centerX: number, centerY: number): void {
273
+ const left = Math.round(centerX - 0.5);
274
+ paint(canvas, left, Math.round(centerY), WOOLY_BLACK);
275
+ paint(canvas, left + 1, Math.round(centerY), WOOLY_BLACK);
276
+ }
277
+
278
+ function drawSmile(canvas: Canvas, centerX: number, centerY: number): void {
279
+ const roundedX = Math.round(centerX);
280
+ const roundedY = Math.round(centerY);
281
+ paint(canvas, roundedX - 2, roundedY, WOOLY_BLACK);
282
+ for (let offset = -1; offset <= 1; offset++) paint(canvas, roundedX + offset, roundedY + 1, WOOLY_BLACK);
283
+ paint(canvas, roundedX + 2, roundedY, WOOLY_BLACK);
284
+ }
285
+
286
+ function drawFace(canvas: Canvas, spec: SpriteSpec, frame: number): void {
287
+ if (frame >= 3 && frame <= 5) return;
288
+ const angle = (frame * Math.PI) / 4;
289
+ const faceX = scaleX(spec.bodyX - Math.sin(angle) * spec.bodyRadius * 0.58);
290
+ const eyeY = spec.bodyY - spec.bodyRadius * 0.4;
291
+ const mouthY = spec.bodyY + spec.bodyRadius * 0.04 - 1;
292
+ if (frame === 2 || frame === 6) {
293
+ drawEye(canvas, faceX, eyeY);
294
+ drawSmile(canvas, faceX, mouthY);
295
+ return;
296
+ }
297
+
298
+ const eyeSpacing = scaleX(frame === 0 ? 2 : 1);
299
+ drawEye(canvas, faceX - eyeSpacing, eyeY);
300
+ drawEye(canvas, faceX + eyeSpacing, eyeY);
301
+ drawSmile(canvas, faceX, mouthY);
302
+ }
303
+
304
+ function liftCanvas(canvas: Canvas, offset: number): Canvas {
305
+ if (offset === 0) return canvas;
306
+ return canvas.map((row, y) => canvas[y + offset]?.slice() ?? Array<Pixel>(row.length).fill(null));
307
+ }
308
+
309
+ function buildSprite(spec: SpriteSpec, animation: WoolyAnimation | null, frame: number): string[] {
310
+ const spinFrame = animation === "spin" ? frame : 0;
311
+ const waveFrame = animation === "wave" ? frame : 0;
312
+ const jumpFrame = animation === "jump" ? frame : 0;
313
+ const retractFrame = animation === "retract" ? frame : 0;
314
+ const canvas = createCanvas(spec);
315
+ drawLimbs(canvas, spec, spinFrame, waveFrame, jumpFrame, retractFrame);
316
+ drawYarnBody(canvas, spec, spinFrame);
317
+ drawFace(canvas, spec, spinFrame);
318
+ const renderedCanvas = liftCanvas(canvas, JUMP_OFFSETS[jumpFrame] ?? 0);
319
+
320
+ const lines: string[] = [];
321
+ for (let y = 0; y < spec.pixelHeight; y += 2) {
322
+ let line = "";
323
+ for (let x = 0; x < spec.width; x++) {
324
+ const pixelX = x * HORIZONTAL_SUBPIXELS;
325
+ line += renderQuadrantBlock(
326
+ renderedCanvas[y]![pixelX]!,
327
+ renderedCanvas[y]![pixelX + 1]!,
328
+ renderedCanvas[y + 1]?.[pixelX] ?? null,
329
+ renderedCanvas[y + 1]?.[pixelX + 1] ?? null,
330
+ );
331
+ }
332
+ lines.push(line);
333
+ }
334
+ return lines;
335
+ }
336
+
337
+ export class WoolyComponent implements Component {
338
+ private interval: ReturnType<typeof setInterval> | undefined;
339
+ private frame = 0;
340
+ private idleElapsedMs = 0;
341
+ private animation: WoolyAnimation | null = null;
342
+ private nextAnimationIndex = 0;
343
+ private cachedKey = "";
344
+ private cachedLines: string[] = [];
345
+
346
+ constructor(private readonly ui: TUI) {
347
+ this.interval = setInterval(() => {
348
+ if (!this.advanceAnimation()) return;
349
+ this.cachedKey = "";
350
+ this.ui.requestRender();
351
+ }, WOOLY_FRAME_INTERVAL_MS);
352
+ this.interval.unref();
353
+ }
354
+
355
+ private advanceAnimation(): boolean {
356
+ if (this.animation === null) {
357
+ this.idleElapsedMs += WOOLY_FRAME_INTERVAL_MS;
358
+ if (this.idleElapsedMs < WOOLY_ANIMATION_DELAY_MS) return false;
359
+ this.animation = ANIMATION_SEQUENCE[this.nextAnimationIndex]!;
360
+ this.nextAnimationIndex = (this.nextAnimationIndex + 1) % ANIMATION_SEQUENCE.length;
361
+ this.frame = 1;
362
+ return true;
363
+ }
364
+ if (this.frame < FRAME_COUNT - 1) {
365
+ this.frame++;
366
+ } else {
367
+ this.frame = 0;
368
+ this.animation = null;
369
+ this.idleElapsedMs = 0;
370
+ }
371
+ return true;
372
+ }
373
+
374
+ invalidate(): void {
375
+ this.cachedKey = "";
376
+ }
377
+
378
+ render(width: number): string[] {
379
+ const rows = this.ui.terminal.rows;
380
+ const mode =
381
+ width < MIN_VISIBLE_WIDTH || rows < MIN_VISIBLE_ROWS
382
+ ? "hidden"
383
+ : width < MIN_FULL_WIDTH || rows < MIN_FULL_ROWS
384
+ ? "compact"
385
+ : "full";
386
+ const cacheKey = `${mode}:${width}:${rows}:${this.animation ?? "idle"}:${this.frame}`;
387
+ if (cacheKey === this.cachedKey) return this.cachedLines;
388
+ if (mode === "hidden") {
389
+ this.cachedLines = [];
390
+ this.cachedKey = cacheKey;
391
+ return this.cachedLines;
392
+ }
393
+
394
+ const spec = mode === "full" ? FULL_SPEC : COMPACT_SPEC;
395
+ const sprite = buildSprite(spec, this.animation, this.frame);
396
+ const leftPadding = Math.max(0, Math.min(LEFT_PADDING, width - spec.width));
397
+ this.cachedLines = sprite.map((line) => `${" ".repeat(leftPadding)}${line}`);
398
+ this.cachedKey = cacheKey;
399
+ return this.cachedLines;
400
+ }
401
+
402
+ dispose(): void {
403
+ if (this.interval !== undefined) {
404
+ clearInterval(this.interval);
405
+ this.interval = undefined;
406
+ }
407
+ }
408
+ }
package/uninstall.sh CHANGED
@@ -523,6 +523,9 @@ if [ -f "$settings_file" ]; then
523
523
  then del(.statusLine) else . end)
524
524
  | (if (.apiKeyHelper // "" | tostring | endswith("weave-key.sh"))
525
525
  then del(.apiKeyHelper) else . end)
526
+ | (if (.attribution.commit == "Co-Authored-By: Weave Router <noreply@workweave.ai>"
527
+ and .attribution.pr == "🤖 Generated with [Weave Router](https://router.workweave.ai)")
528
+ then del(.attribution) else . end)
526
529
  ' "$settings_file")"
527
530
  printf '%s\n' "$cleaned" >"$settings_file"
528
531
  ok "Cleaned $settings_file"