@workweave/router 0.2.5 → 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,191 @@
1
+ import { MODEL_PRICING, PRICING_VERSION } from "./pricing.generated.js";
2
+
3
+ export const SAVINGS_ENTRY_TYPE = "weave-router-savings-v1";
4
+
5
+ export interface TokenUsage {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ }
11
+
12
+ export interface RouteDecision {
13
+ requestedModel: string;
14
+ routedModel: string;
15
+ provider?: string;
16
+ decision?: string;
17
+ }
18
+
19
+ export interface SavingsEntryData {
20
+ version: 1;
21
+ pricingVersion: string;
22
+ requestedModel: string;
23
+ routedModel: string;
24
+ provider?: string;
25
+ decision?: string;
26
+ usage: TokenUsage;
27
+ requestedCostUsd?: number;
28
+ routedCostUsd?: number;
29
+ savingsUsd?: number;
30
+ priced: boolean;
31
+ unpricedModels: string[];
32
+ }
33
+
34
+ export interface SavingsAggregate {
35
+ totalSavingsUsd: number;
36
+ pricedResponses: number;
37
+ unpricedResponses: number;
38
+ lastEntry?: SavingsEntryData;
39
+ }
40
+
41
+ function finiteNonNegative(value: number): number | undefined {
42
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
43
+ }
44
+
45
+ export function normalizeModelId(model: string): string {
46
+ return model.trim().replace(/^weave\//, "").replace(/\[[^\]]*\]$/, "").replace(/-[0-9]{8}$/, "");
47
+ }
48
+
49
+ function normalizedUsage(usage: TokenUsage): TokenUsage | undefined {
50
+ const input = finiteNonNegative(usage.input);
51
+ const output = finiteNonNegative(usage.output);
52
+ const cacheRead = finiteNonNegative(usage.cacheRead);
53
+ const cacheWrite = finiteNonNegative(usage.cacheWrite);
54
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) {
55
+ return undefined;
56
+ }
57
+ return { input, output, cacheRead, cacheWrite };
58
+ }
59
+
60
+ function modelCostUsd(model: string, usage: TokenUsage): number | undefined {
61
+ const price = MODEL_PRICING[normalizeModelId(model)];
62
+ if (!price) return undefined;
63
+ const inputTokens = usage.input + 1.25 * usage.cacheWrite + 0.1 * usage.cacheRead;
64
+ return (inputTokens * price.inputUsdPerMillion + usage.output * price.outputUsdPerMillion) / 1_000_000;
65
+ }
66
+
67
+ export function createSavingsEntry(decision: RouteDecision, rawUsage: TokenUsage): SavingsEntryData {
68
+ const requestedModel = normalizeModelId(decision.requestedModel);
69
+ const routedModel = normalizeModelId(decision.routedModel);
70
+ const usage = normalizedUsage(rawUsage);
71
+ const base: SavingsEntryData = {
72
+ version: 1,
73
+ pricingVersion: PRICING_VERSION,
74
+ requestedModel,
75
+ routedModel,
76
+ ...(decision.provider ? { provider: decision.provider } : {}),
77
+ ...(decision.decision ? { decision: decision.decision } : {}),
78
+ usage: usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
79
+ priced: false,
80
+ unpricedModels: [],
81
+ };
82
+
83
+ if (!usage || !requestedModel || !routedModel) return base;
84
+
85
+ // An unchanged route has an exact zero delta even if the catalog does not
86
+ // know the model. Avoid calling a no-op response "unpriced" when no price
87
+ // comparison is necessary.
88
+ if (requestedModel === routedModel) {
89
+ const cost = modelCostUsd(requestedModel, usage);
90
+ return {
91
+ ...base,
92
+ ...(cost === undefined ? {} : { requestedCostUsd: cost, routedCostUsd: cost }),
93
+ savingsUsd: 0,
94
+ priced: true,
95
+ };
96
+ }
97
+
98
+ const requestedCostUsd = modelCostUsd(requestedModel, usage);
99
+ const routedCostUsd = modelCostUsd(routedModel, usage);
100
+ if (requestedCostUsd === undefined || routedCostUsd === undefined) {
101
+ const unpricedModels = [
102
+ ...(requestedCostUsd === undefined ? [requestedModel] : []),
103
+ ...(routedCostUsd === undefined ? [routedModel] : []),
104
+ ];
105
+ return { ...base, unpricedModels };
106
+ }
107
+
108
+ return {
109
+ ...base,
110
+ requestedCostUsd,
111
+ routedCostUsd,
112
+ savingsUsd: requestedCostUsd - routedCostUsd,
113
+ priced: true,
114
+ };
115
+ }
116
+
117
+ function isRecord(value: unknown): value is Record<string, unknown> {
118
+ return typeof value === "object" && value !== null;
119
+ }
120
+
121
+ function isTokenUsage(value: unknown): value is TokenUsage {
122
+ if (!isRecord(value)) return false;
123
+ return [value.input, value.output, value.cacheRead, value.cacheWrite].every(
124
+ (token) => typeof token === "number" && Number.isFinite(token) && token >= 0,
125
+ );
126
+ }
127
+
128
+ export function isSavingsEntryData(value: unknown): value is SavingsEntryData {
129
+ if (!isRecord(value)) return false;
130
+ return (
131
+ value.version === 1 &&
132
+ typeof value.pricingVersion === "string" &&
133
+ typeof value.requestedModel === "string" &&
134
+ typeof value.routedModel === "string" &&
135
+ isTokenUsage(value.usage) &&
136
+ typeof value.priced === "boolean" &&
137
+ (value.provider === undefined || typeof value.provider === "string") &&
138
+ (value.decision === undefined || typeof value.decision === "string") &&
139
+ (value.requestedCostUsd === undefined ||
140
+ (typeof value.requestedCostUsd === "number" && Number.isFinite(value.requestedCostUsd))) &&
141
+ (value.routedCostUsd === undefined ||
142
+ (typeof value.routedCostUsd === "number" && Number.isFinite(value.routedCostUsd))) &&
143
+ Array.isArray(value.unpricedModels) &&
144
+ value.unpricedModels.every((model) => typeof model === "string") &&
145
+ (value.savingsUsd === undefined || (typeof value.savingsUsd === "number" && Number.isFinite(value.savingsUsd)))
146
+ );
147
+ }
148
+
149
+ export function aggregateSavings(entries: Iterable<SavingsEntryData>): SavingsAggregate {
150
+ let totalSavingsUsd = 0;
151
+ let pricedResponses = 0;
152
+ let unpricedResponses = 0;
153
+ let lastEntry: SavingsEntryData | undefined;
154
+ for (const entry of entries) {
155
+ lastEntry = entry;
156
+ if (entry.priced && entry.savingsUsd !== undefined) {
157
+ totalSavingsUsd += entry.savingsUsd;
158
+ pricedResponses++;
159
+ } else {
160
+ unpricedResponses++;
161
+ }
162
+ }
163
+ return {
164
+ totalSavingsUsd,
165
+ pricedResponses,
166
+ unpricedResponses,
167
+ ...(lastEntry ? { lastEntry } : {}),
168
+ };
169
+ }
170
+
171
+ export function formatMoney(amount: number): string {
172
+ const absolute = Math.abs(amount);
173
+ if (absolute > 0 && absolute < 0.005) return "<$0.01";
174
+ return `$${absolute.toFixed(2)}`;
175
+ }
176
+
177
+ export function formatSavings(aggregate: SavingsAggregate): string {
178
+ let clause: string;
179
+ if (aggregate.pricedResponses === 0) {
180
+ clause = "saved —";
181
+ } else if (aggregate.totalSavingsUsd < 0) {
182
+ clause = `extra ${formatMoney(aggregate.totalSavingsUsd)}`;
183
+ } else {
184
+ clause = `saved ${formatMoney(aggregate.totalSavingsUsd)}`;
185
+ }
186
+ if (aggregate.unpricedResponses > 0) {
187
+ const suffix = aggregate.unpricedResponses === 1 ? "1 unpriced" : `${aggregate.unpricedResponses} unpriced`;
188
+ return `${clause} · ${suffix}`;
189
+ }
190
+ return clause;
191
+ }
@@ -0,0 +1,80 @@
1
+ import type { ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
2
+ import type { TUI } from "@mariozechner/pi-tui";
3
+ import type { SavingsAggregate } from "./savings.js";
4
+ import { formatSavings } from "./savings.js";
5
+ import { WoolyComponent } from "./wooly.js";
6
+
7
+ const STATUS_KEY = "weave-router";
8
+ const WOOLY_WIDGET_KEY = "weave-wooly";
9
+ const BRAND_OPEN = "\x1b[38;2;255;108;71m";
10
+ const BRAND_CLOSE = "\x1b[39m";
11
+
12
+ const WEAVE_WORDMARK = ["╦ ╦╔═╗╔═╗╦ ╦╔═╗", "║║║║╣ ╠═╣╚╗╔╝║╣ ", "╚╩╝╚═╝╩ ╩ ╚╝ ╚═╝"] as const;
13
+ const LOOM_WORDMARK = [
14
+ "██╗ ███████╗ ███████╗ ███╗ ███╗",
15
+ "██║ ██╔═══██╗██╔═══██╗████╗ ████║",
16
+ "██║ ██║ ██║██║ ██║██╔████╔██║",
17
+ "██║ ██║ ██║██║ ██║██║╚██╔╝██║",
18
+ "██████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║",
19
+ "╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
20
+ ] as const;
21
+
22
+ export interface RouterUiSnapshot {
23
+ requestedModel?: string;
24
+ routedModel?: string;
25
+ forcedModel?: string;
26
+ savings: SavingsAggregate;
27
+ }
28
+
29
+ function brand(text: string): string {
30
+ return `${BRAND_OPEN}${text}${BRAND_CLOSE}`;
31
+ }
32
+
33
+ function headerLines(theme: Theme, width: number): string[] {
34
+ const weave = WEAVE_WORDMARK.map((line) => theme.bold(brand(line)));
35
+ if (width < 44) {
36
+ return ["", ...weave, theme.bold("LOOM"), theme.fg("dim", "Weave Router · Loom for Pi"), ""];
37
+ }
38
+ return [
39
+ "",
40
+ ...weave,
41
+ ...LOOM_WORDMARK.map((line) => theme.bold(line)),
42
+ theme.bold("Weave Router · Loom for Pi"),
43
+ "",
44
+ ];
45
+ }
46
+
47
+ export function installLoomUi(ctx: ExtensionContext): void {
48
+ if (ctx.mode !== "tui") return;
49
+ ctx.ui.setTitle("Loom · Weave Router");
50
+ ctx.ui.setHeader((_tui: TUI, theme: Theme) => ({
51
+ invalidate() {},
52
+ render(width: number): string[] {
53
+ return headerLines(theme, width);
54
+ },
55
+ }));
56
+ ctx.ui.setWidget(WOOLY_WIDGET_KEY, (tui: TUI) => new WoolyComponent(tui), { placement: "belowEditor" });
57
+ }
58
+
59
+ export function clearLoomUi(ctx: ExtensionContext): void {
60
+ if (!ctx.hasUI) return;
61
+ ctx.ui.setStatus(STATUS_KEY, undefined);
62
+ if (ctx.mode === "tui") {
63
+ ctx.ui.setWidget(WOOLY_WIDGET_KEY, undefined);
64
+ ctx.ui.setHeader(undefined);
65
+ }
66
+ }
67
+
68
+ export function updateRouterStatus(ctx: ExtensionContext, snapshot: RouterUiSnapshot): void {
69
+ if (!ctx.hasUI) return;
70
+ const { requestedModel, routedModel, forcedModel, savings } = snapshot;
71
+ let route: string;
72
+ if (routedModel && requestedModel) route = `${routedModel} ← ${requestedModel}`;
73
+ else if (routedModel) route = routedModel;
74
+ else if (requestedModel) route = `${requestedModel} · awaiting route`;
75
+ else route = "automatic routing";
76
+
77
+ const label = ctx.mode === "tui" ? ctx.ui.theme.bold(brand("WEAVE ROUTER")) : "WEAVE ROUTER";
78
+ const detail = forcedModel ? `${forcedModel} [forced]` : `${route} · ${formatSavings(savings)}`;
79
+ ctx.ui.setStatus(STATUS_KEY, `${label} — ${ctx.mode === "tui" ? ctx.ui.theme.fg("dim", detail) : detail}`);
80
+ }
@@ -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
+ }