diff --git a/build.mjs b/build.mjs index 027677e..1291e47 100644 --- a/build.mjs +++ b/build.mjs @@ -80,22 +80,46 @@ for (const skillName of ["supermemory-status"]) { ); } -// Codex custom TUI pets use a fixed 8x9 spritesheet. Every frame in this -// sheet is intentionally identical: Supermemory needs a persistent activity -// badge, not an animated mascot that competes with the coding surface. +// Codex custom TUI pets use a fixed 8x9 spritesheet. Keep Supermemory as a +// quiet badge, but use the standard animation rows to reflect Codex activity. const PET_FRAME_WIDTH = 192; const PET_FRAME_HEIGHT = 208; const PET_COLUMNS = 8; const PET_ROWS = 9; +const PET_ROW_STYLES = [ + { state: "ACTIVE", accent: [139, 124, 255, 255], frames: 6 }, + { state: "RUNNING", accent: [59, 130, 246, 255], frames: 8 }, + { state: "RUNNING", accent: [59, 130, 246, 255], frames: 8 }, + { state: "READY", accent: [34, 197, 94, 255], frames: 4 }, + { state: "READY", accent: [34, 197, 94, 255], frames: 5 }, + { state: "BLOCKED", accent: [239, 68, 68, 255], frames: 8 }, + { state: "NEEDS INPUT", accent: [245, 158, 11, 255], frames: 6 }, + { state: "RUNNING", accent: [59, 130, 246, 255], frames: 6 }, + { state: "READY", accent: [34, 197, 94, 255], frames: 6 }, +]; + +const PET_PULSE = [0.72, 0.84, 1, 0.9, 0.78, 0.9, 1, 0.84]; + const PET_FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "11001", "10101", "10011", "10011", "10001"], O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], }; @@ -155,6 +179,10 @@ function fillRoundedRect(pixels, width, x, y, rectWidth, rectHeight, radius, col function drawText(pixels, width, text, x, y, scale, color) { let cursorX = x; for (const character of text) { + if (character === " ") { + cursorX += 4 * scale; + continue; + } const glyph = PET_FONT[character]; if (!glyph) continue; glyph.forEach((row, rowIndex) => { @@ -176,11 +204,20 @@ function drawText(pixels, width, text, x, y, scale, color) { } } -function drawPetFrame(pixels, sheetWidth, frameX, frameY) { +function pulseColor(color, column) { + const factor = PET_PULSE[column % PET_PULSE.length]; + return color.map((channel, index) => index === 3 ? channel : Math.round(channel * factor)); +} + +function drawPetFrame(pixels, sheetWidth, frameX, frameY, row, column) { + const style = PET_ROW_STYLES[row]; + if (!style || column >= style.frames) return; + + const accent = pulseColor(style.accent, column); const badgeX = frameX + 6; - const badgeY = frameY + 164; - fillRoundedRect(pixels, sheetWidth, badgeX, badgeY, 180, 36, 10, [24, 24, 27, 235]); - fillRoundedRect(pixels, sheetWidth, badgeX + 10, badgeY + 9, 18, 18, 3, [139, 124, 255, 255]); + const badgeY = frameY + 156; + fillRoundedRect(pixels, sheetWidth, badgeX, badgeY, 180, 44, 10, [24, 24, 27, 235]); + fillRoundedRect(pixels, sheetWidth, badgeX + 10, badgeY + 13, 18, 18, 3, accent); // A tiny diagonal cut inside the square echoes the mark used by hook notices. for (let row = 0; row < 12; row += 1) { @@ -189,7 +226,7 @@ function drawPetFrame(pixels, sheetWidth, frameX, frameY) { pixels, sheetWidth, badgeX + 13 + column, - badgeY + 12 + row, + badgeY + 16 + row, [242, 240, 255, 255], ); } @@ -200,10 +237,20 @@ function drawPetFrame(pixels, sheetWidth, frameX, frameY) { sheetWidth, "SUPERMEMORY", badgeX + 36, - badgeY + 11, + badgeY + 7, 2, [226, 222, 255, 255], ); + + drawText( + pixels, + sheetWidth, + style.state, + badgeX + 36, + badgeY + 27, + 1, + accent, + ); } function writePetSpritesheet(outputPath) { @@ -218,6 +265,8 @@ function writePetSpritesheet(outputPath) { width, column * PET_FRAME_WIDTH, row * PET_FRAME_HEIGHT, + row, + column, ); } } diff --git a/src/pet/pet.json b/src/pet/pet.json index c05a92d..e3acc78 100644 --- a/src/pet/pet.json +++ b/src/pet/pet.json @@ -1,7 +1,7 @@ { "id": "supermemory", "displayName": "Supermemory", - "description": "A quiet persistent indicator that Supermemory is installed", + "description": "A quiet state-aware indicator that Supermemory is installed", "spritesheetPath": "spritesheet.png", "frame": { "width": 192, diff --git a/test/unit.mjs b/test/unit.mjs index c9a2ea4..b53cd47 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -10,6 +10,7 @@ import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { inflateSync } from "node:zlib"; import * as TOML from "@iarna/toml"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -47,6 +48,38 @@ function hash16(input) { return createHash("sha256").update(input).digest("hex").slice(0, 16); } +function readGeneratedPetPng(path) { + const png = readFileSync(path); + let offset = 8; + let width = 0; + let height = 0; + const idat = []; + + while (offset < png.length) { + const length = png.readUInt32BE(offset); + const type = png.toString("ascii", offset + 4, offset + 8); + const data = png.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + } else if (type === "IDAT") { + idat.push(data); + } + offset += 12 + length; + } + + const scanlines = inflateSync(Buffer.concat(idat)); + const stride = width * 4 + 1; + return { + width, + height, + pixel(x, y) { + assert.equal(scanlines[y * stride], 0, "generated pet PNG must use filter 0"); + return [...scanlines.subarray(y * stride + 1 + x * 4, y * stride + 1 + x * 4 + 4)]; + }, + }; +} + // Inline the stripPrivateContent logic (mirrors src/services/privacy.ts exactly) function stripPrivateContent(s) { return s.replace(/[\s\S]*?<\/private>/gi, "[REDACTED]"); @@ -790,6 +823,24 @@ describe("hooks.json format", () => { describe("integration: install/uninstall", () => { const cliBin = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + test("generated pet badge reflects Codex states", () => { + const pet = readGeneratedPetPng(fileURLToPath(new URL("../dist/pet/spritesheet.png", import.meta.url))); + assert.equal(pet.width, 1536); + assert.equal(pet.height, 1872); + + const accentAtRow = (row) => pet.pixel(17, row * 208 + 178); + const running = accentAtRow(7); + const ready = accentAtRow(8); + const blocked = accentAtRow(5); + const waiting = accentAtRow(6); + + assert.ok(running[2] > running[0] && running[2] > running[1], "running accent should be blue"); + assert.ok(ready[1] > ready[0] && ready[1] > ready[2], "ready accent should be green"); + assert.ok(blocked[0] > blocked[1] && blocked[0] > blocked[2], "blocked accent should be red"); + assert.ok(waiting[0] > waiting[1] && waiting[1] > waiting[2], "needs-input accent should be amber"); + assert.equal(pet.pixel(7 * 192 + 17, 178)[3], 0, "unused idle frames should be transparent"); + }); + test("install keeps only status skill and registers hosted MCP", (t) => { const { tmpDir, codexDir } = setupCodexHome(t);