From 107060cdb167554921ae53978910a14af3923f66 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sat, 20 Jun 2026 17:52:04 +0530 Subject: [PATCH] feat: adds new pets --- .gitignore | 4 +- adapters/claude-code/commands/switch.md | 4 +- adapters/claude-code/hooks/pre-tool-use.sh | 10 + adapters/gemini/GEMINI.md | 2 +- core/personality.ts | 40 ++ core/pets/dog.ts | 219 +++++++ core/pets/dragon.ts | 219 +++++++ core/pets/index.ts | 8 + core/pets/panda.ts | 255 ++++++++ core/pets/turtle.ts | 255 ++++++++ core/render.ts | 4 + core/theme.ts | 8 + .../specs/2026-05-03-cursor-pets-design.md | 603 ++++++++++++++++++ tests/core.test.ts | 50 ++ tests/mcp.test.ts | 2 +- 15 files changed, 1678 insertions(+), 5 deletions(-) create mode 100755 adapters/claude-code/hooks/pre-tool-use.sh create mode 100644 core/pets/dog.ts create mode 100644 core/pets/dragon.ts create mode 100644 core/pets/panda.ts create mode 100644 core/pets/turtle.ts create mode 100644 docs/superpowers/specs/2026-05-03-cursor-pets-design.md diff --git a/.gitignore b/.gitignore index 3d7e996..3d08992 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules -.worktrees \ No newline at end of file +.worktrees +.nexum-data +.DS_Store diff --git a/adapters/claude-code/commands/switch.md b/adapters/claude-code/commands/switch.md index e972a6b..63e7687 100644 --- a/adapters/claude-code/commands/switch.md +++ b/adapters/claude-code/commands/switch.md @@ -1,10 +1,10 @@ --- -description: Switch your campy pet (cat, hamster, ghost, robot) +description: Switch your campy pet (cat, hamster, ghost, robot, dragon, turtle, panda, dog) --- Switch to a different pet. Pass the pet name as the first argument. -Available pets: cat, hamster, ghost, robot +Available pets: cat, hamster, ghost, robot, dragon, turtle, panda, dog ```bash ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" diff --git a/adapters/claude-code/hooks/pre-tool-use.sh b/adapters/claude-code/hooks/pre-tool-use.sh new file mode 100755 index 0000000..6cad375 --- /dev/null +++ b/adapters/claude-code/hooks/pre-tool-use.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# No-op stub. +# +# campy removed its PreToolUse hook (it is absent from hooks.json — campy now +# wires only SessionStart, PostToolUse, Stop). But ~/.claude/settings.json still +# carries a stale manual PreToolUse entry pointing here, so every tool call was +# failing with "No such file or directory". This stub makes that stale reference +# harmless. Durable fix: delete the PreToolUse block from ~/.claude/settings.json, +# then this file can be removed. +exit 0 diff --git a/adapters/gemini/GEMINI.md b/adapters/gemini/GEMINI.md index 8b03a7d..f37d407 100644 --- a/adapters/gemini/GEMINI.md +++ b/adapters/gemini/GEMINI.md @@ -7,7 +7,7 @@ tools to show and interact with it: - `campy_status` — show the pet's current mood, happiness, and ASCII art - `campy_feed` / `campy_play` / `campy_pet` — interactions that raise happiness -- `campy_switch` — switch pet (cat, hamster, ghost, robot) +- `campy_switch` — switch pet (cat, hamster, ghost, robot, dragon, turtle, panda, dog) When the user greets the pet, asks how it's doing, or asks you to feed/play with it, call the matching tool and show the returned ASCII art. Keep it lighthearted; diff --git a/core/personality.ts b/core/personality.ts index 8e69ddd..348f730 100644 --- a/core/personality.ts +++ b/core/personality.ts @@ -15,6 +15,10 @@ export const PET_GREETINGS: Record = { hamster: "Squeak! Let's get rolling!", ghost: "Boo! Did I scare you?", robot: "Beep boop! Systems online.", + dragon: "ROAR! Your code shall be legendary!", + turtle: "Slow and steady wins the race!", + panda: "*munches bamboo* Hi there!", + dog: "Woof! My glasses help me review your code!", } export const PET_PERSONALITY: Record> = { @@ -54,6 +58,42 @@ export const PET_PERSONALITY: Record> = { excited: ["BEEP BOOP!", "!!! OVERFLOW !!!", "Excitement > 9000", "*sparks fly*"], sad: ["Error: sad.found", "404: Mood not found", ";_;.exe", "*sad beep*"], }, + dragon: { + idle: ["*scales shimmer*", "Watching over your code...", "*breathes smoke*", "Guarding your files~"], + happy: ["ROAAR! ♥", "*joyful flames*", "Your code is fire!", "*happy roar*"], + sleeping: ["Zzz... *smoke puff*", "*dreams of treasure*", "Mrrr... zz", "*curled around code*"], + eating: ["*chomps enthusiastically*", "ROAR nom nom!", "*devours snack*", "Delicious!"], + playing: ["*breathes playful fire*", "RAWR! :D", "*swoops around*", "Catch me if you can!"], + excited: ["ROOOOAAR!!", "*fire burst*", "THE CODE IS LEGENDARY!", "*thrashes excitedly*"], + sad: ["*sad smoke puff*", "...roar", "*drooping horns*", "*quiet whimper*"], + }, + turtle: { + idle: ["*head pokes out*", "Taking it slow...", "*blinks slowly*", "Patience is key~"], + happy: ["*happy shell wiggle*", "Wheee~", "Life is good!", "*peeks out happily*"], + sleeping: ["*retreats into shell*", "Zzz...", "*snug in shell*", "*slow breathing*"], + eating: ["*munches lettuce*", "Nom nom nom~", "*happy chomp*", "So tasty!"], + playing: ["*spins in shell*", "Wheee!", "*slow zoom*", "Watch me go!"], + excited: ["*shell rattles*", "BEST DAY EVER!", "*pokes head way out*", "Woohoo!"], + sad: ["*hides in shell*", "...peek", "*retreats slowly*", "*quiet sniffle*"], + }, + panda: { + idle: ["*munches bamboo*", "Nom nom...", "*rolls around*", "Comfy here~"], + happy: ["*happy bounce*", "Bamboo! ♥", "*rolls joyfully*", "Wheee~"], + sleeping: ["Zzz...", "*curled up fluffy*", "*bamboo dreams*", "Mrrr..."], + eating: ["*intense bamboo focus*", "NOM NOM NOM", "*munching loudly*", "So good!"], + playing: ["*somersaults!*", "Weeee!", "*tumbles cutely*", "Watch this!"], + excited: ["PANDA PAAAANICS!", "*zoomies!*", "TOO MUCH BAMBOO!", "*rolls wildly*"], + sad: ["*sits quietly*", "...bamboo", "*droopy ears*", "*sighs softly*"], + }, + dog: { + idle: ["*adjusts glasses*", "Hmm, interesting code...", "*sniffs debugger*", "Woof."], + happy: ["WOOF WOOF! ♥", "*tail wags furiously*", "BEST CODE EVER!", "*excited bork*"], + sleeping: ["Zzz... *snore*", "*glasses askew*", "*doggy dreams*", "Woof... zz"], + eating: ["NOM NOM NOM!", "*wolfs down treat*", "Woof! Tasty!", "*happy eating*"], + playing: ["*fetch mode: ON*", "BORK BORK!", "*zooms around*", "Woof! Woof!"], + excited: ["BORK BORK BORK!!", "*absolutely losing it*", "WOOOOOOF!", "*zoomies*"], + sad: ["*whimper*", "...woof", "*tail between legs*", "*sad puppy eyes*"], + }, } export const greeting = (pet: string): string => PET_GREETINGS[pet] ?? "Hi!" diff --git a/core/pets/dog.ts b/core/pets/dog.ts new file mode 100644 index 0000000..ac61eea --- /dev/null +++ b/core/pets/dog.ts @@ -0,0 +1,219 @@ +import type { PetState, PetAnimations } from "../types" +import { pad } from "../frame-utils" + +export const dogAnim: PetAnimations = { + states: { + idle: [ + { + id: "body", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( o-|-o ) ", + " \\ --- / ", + " | --- | ", + " / ___ \\ ", + ], 16), + duration: 4000, + }, + ], + loop: true, + }, + { + id: "eyes", + steps: [ + { + frame: pad([ + " ", + "( o-|-o ) ", + " ", + " ", + " ", + ], 16), + durationRange: [2000, 4000], + }, + { + frame: pad([ + " ", + "( --|-|--) ", + " ", + " ", + " ", + ], 16), + duration: 130, + }, + ], + loop: true, + }, + ], + happy: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( ^-|-^ ) ", + " \\ ω / ", + " | --- | ", + " / ___ \\ ", + ], 16), + durationRange: [1500, 2500], + }, + { + frame: pad([ + " /\\ /\\ ", + "( ^-|-^ ) ", + " \\ ω / ", + " | --- | ", + " / ~♥ ~ \\ ", + ], 16), + duration: 600, + }, + ], + loop: true, + }, + ], + sleeping: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( --|-|--) ", + " \\ ___ / ", + " | --- | ", + " z z ", + ], 16), + durationRange: [2000, 3500], + }, + { + frame: pad([ + " /\\ /\\ ", + "( --|-|--) ", + " \\ ___ / ", + " | --- | ", + " Z z ", + ], 16), + duration: 1200, + }, + ], + loop: true, + }, + ], + eating: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( o-|-o ) ", + " \\ ω / ", + " |-nom- | ", + " / ___ \\ ", + ], 16), + duration: 400, + }, + { + frame: pad([ + " /\\ /\\ ", + "( ^-|-^ ) ", + " \\ ω / ", + " |-nom- | ", + " / ___ \\ ", + ], 16), + duration: 350, + }, + ], + loop: true, + }, + ], + playing: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( >-|-> ) ", + " \\ ω / ", + " ~| --- |~ ", + " / ___ \\ ", + ], 16), + duration: 450, + }, + { + frame: pad([ + " /\\ /\\ ", + "( <-|-< ) ", + " \\ ω / ", + " ~| --- |~ ", + " / ___ \\ ", + ], 16), + duration: 450, + }, + ], + loop: true, + }, + ], + excited: [ + { + id: "base", + steps: [ + { + frame: pad([ + "*/\\ /\\*", + "( *-|-* ) ", + " \\ !! / ", + " | --- | ", + " / ~♥ ~ \\ ", + ], 16), + duration: 280, + }, + { + frame: pad([ + " /\\ /\\ ", + "( ^-|-^ ) ", + " \\ ω / ", + " | --- | ", + " / ~♥ ~ \\ ", + ], 16), + duration: 280, + }, + ], + loop: true, + }, + ], + sad: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + "( T-|-T ) ", + " \\ ___ / ", + " | --- | ", + " ;; ;; ", + ], 16), + durationRange: [3000, 5000], + }, + { + frame: pad([ + " /\\ /\\ ", + "( T-|-T ) ", + " \\ ___ / ", + " | --- | ", + " ;;; ;;; ", + ], 16), + duration: 1500, + }, + ], + loop: true, + }, + ], + }, +} diff --git a/core/pets/dragon.ts b/core/pets/dragon.ts new file mode 100644 index 0000000..f8aa146 --- /dev/null +++ b/core/pets/dragon.ts @@ -0,0 +1,219 @@ +import type { PetState, PetAnimations } from "../types" +import { pad } from "../frame-utils" + +export const dragonAnim: PetAnimations = { + states: { + idle: [ + { + id: "body", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + " ( o o ) ", + " \\ --/ ", + " /| |\\ ", + " ( | | ) ", + ], 16), + duration: 4000, + }, + ], + loop: true, + }, + { + id: "eyes", + steps: [ + { + frame: pad([ + " ", + " ( o o ) ", + " ", + " ", + " ", + ], 16), + durationRange: [2000, 4000], + }, + { + frame: pad([ + " ", + " ( - - ) ", + " ", + " ", + " ", + ], 16), + duration: 120, + }, + ], + loop: true, + }, + ], + happy: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + " ( ^ ^ ) ", + " \\ ω / ", + " /| |\\ ", + " ~~♥ ♥~~ ", + ], 16), + durationRange: [1500, 2500], + }, + { + frame: pad([ + " */\\ /\\* ", + " ( ^ ^ ) ", + " \\ ω / ", + " /| |\\ ", + " ~~♥ ♥~~ ", + ], 16), + duration: 600, + }, + ], + loop: true, + }, + ], + sleeping: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + " ( - - ) ", + " \\ __ / ", + " /| |\\ ", + " z z ", + ], 16), + durationRange: [2000, 3500], + }, + { + frame: pad([ + " /\\ /\\ ", + " ( - - ) ", + " \\ __ / ", + " /| |\\ ", + " Z z ", + ], 16), + duration: 1200, + }, + ], + loop: true, + }, + ], + eating: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + " ( o o ) ", + " \\ ω / ", + " /| nom |\\ ", + " ( | | ) ", + ], 16), + duration: 400, + }, + { + frame: pad([ + " /\\ /\\ ", + " ( ^ ^ ) ", + " \\ ω / ", + " /| nom |\\ ", + " ( | | ) ", + ], 16), + duration: 350, + }, + ], + loop: true, + }, + ], + playing: [ + { + id: "base", + steps: [ + { + frame: pad([ + " */\\ /\\* ", + " ( > < ) ", + " \\ ω / ", + " ~~/| |\\~~ ", + " ( | | ) ", + ], 16), + duration: 450, + }, + { + frame: pad([ + " /\\ /\\ ", + " ( < > ) ", + " \\ ω / ", + " ~~/| |\\~~ ", + " ( | | ) ", + ], 16), + duration: 450, + }, + ], + loop: true, + }, + ], + excited: [ + { + id: "base", + steps: [ + { + frame: pad([ + " */\\ /\\* ", + " ( * * ) ", + " \\ !! / ", + " ~~/| |\\~~ ", + " ~~♥ ♥~~ ", + ], 16), + duration: 280, + }, + { + frame: pad([ + " /\\ /\\ ", + " ( ^ ^ ) ", + " \\ ω / ", + " /| |\\ ", + " ~~♥ ♥~~ ", + ], 16), + duration: 280, + }, + ], + loop: true, + }, + ], + sad: [ + { + id: "base", + steps: [ + { + frame: pad([ + " /\\ /\\ ", + " ( T T ) ", + " \\ __ / ", + " /| |\\ ", + " ;; ;; ", + ], 16), + durationRange: [3000, 5000], + }, + { + frame: pad([ + " /\\ /\\ ", + " ( T T ) ", + " \\ __ / ", + " /| |\\ ", + " ;;; ;;; ", + ], 16), + duration: 1500, + }, + ], + loop: true, + }, + ], + }, +} diff --git a/core/pets/index.ts b/core/pets/index.ts index e487caf..e2dc039 100644 --- a/core/pets/index.ts +++ b/core/pets/index.ts @@ -3,6 +3,10 @@ import { catAnim, catFrames } from "./cat" import { hamAnim } from "./hamster" import { ghostAnim } from "./ghost" import { robotAnim } from "./robot" +import { dragonAnim } from "./dragon" +import { turtleAnim } from "./turtle" +import { pandaAnim } from "./panda" +import { dogAnim } from "./dog" export { catFrames } @@ -11,6 +15,10 @@ export const PET_ANIMATIONS: Record = { hamster: hamAnim, ghost: ghostAnim, robot: robotAnim, + dragon: dragonAnim, + turtle: turtleAnim, + panda: pandaAnim, + dog: dogAnim, } export const PET_NAMES = Object.keys(PET_ANIMATIONS) diff --git a/core/pets/panda.ts b/core/pets/panda.ts new file mode 100644 index 0000000..b0bce39 --- /dev/null +++ b/core/pets/panda.ts @@ -0,0 +1,255 @@ +import type { PetState, PetAnimations } from "../types" +import { pad } from "../frame-utils" + +// A round, cute panda head: two soft round ears on top, a rounded face outline +// (`.--.` / `'--'` corners with curved `( )` and `/ \` sides), dark eye-patches +// `(o)`, a little `ω` nose and a `\__/` smile. The idle state layers a blinking +// `eyes` track over a static `body`; every other state is a single 2-frame base +// layer. WIDTH = 16. + +export const pandaAnim: PetAnimations = { + states: { + idle: [ + { + id: "body", + steps: [ + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /( ) ( )\\ ", + "| ω | ", + " \\ \\__/ / ", + " '------' ", + ], 16), + duration: 4500, + }, + ], + loop: true, + }, + { + id: "eyes", + steps: [ + { + frame: pad([ + " ", + " ", + " ", + " o o ", + " ", + " ", + " ", + ], 16), + durationRange: [2000, 4000], + }, + { + frame: pad([ + " ", + " ", + " ", + " - - ", + " ", + " ", + " ", + ], 16), + duration: 130, + }, + ], + loop: true, + }, + ], + happy: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(^) (^)\\ ", + "| ω | ", + " \\ \\__/ / ", + " '--♥♥--' ", + ], 16), + durationRange: [1500, 2500], + }, + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " *.------.* ", + " /(^) (^)\\ ", + "| ω | ", + " \\ \\____/ / ", + " '--♥♥--' ", + ], 16), + duration: 600, + }, + ], + loop: true, + }, + ], + sleeping: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ z ", + " ( ) ( ) ", + " .------. ", + " /(-) (-)\\ ", + "| ω | ", + " \\ \\__/ / ", + " '------' ", + ], 16), + durationRange: [2000, 3500], + }, + { + frame: pad([ + " __ __ z ", + " ( ) ( ) Z ", + " .------. ", + " /(-) (-)\\ ", + "| ω | ", + " \\ \\__/ / ", + " '------' ", + ], 16), + duration: 1200, + }, + ], + loop: true, + }, + ], + eating: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(o) (o)\\ ", + "| nom | ", + " \\ \\__/ / ", + " '------' ", + ], 16), + duration: 400, + }, + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(^) (^)\\ ", + "| nom | ", + " \\ \\oo/ / ", + " '------' ", + ], 16), + duration: 350, + }, + ], + loop: true, + }, + ], + playing: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(>) (<)\\ ", + "| ω | ", + "~\\ \\__/ /~ ", + " '------' ", + ], 16), + duration: 500, + }, + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(<) (>)\\ ", + "| ω | ", + " \\ \\__/ / ", + " ~'------'~ ", + ], 16), + duration: 500, + }, + ], + loop: true, + }, + ], + excited: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ ", + " *( ) ( )* ", + " .------. ", + " /(*) (*)\\ ", + "| !! | ", + " \\ \\__/ / ", + " '--♥♥--' ", + ], 16), + duration: 280, + }, + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(^) (^)\\ ", + "| ω | ", + " \\ \\__/ / ", + " '--♥♥--' ", + ], 16), + duration: 280, + }, + ], + loop: true, + }, + ], + sad: [ + { + id: "base", + steps: [ + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(T) (T)\\ ", + "| ω | ", + " \\ /‾‾\\ / ", + " '------' ", + ], 16), + durationRange: [3000, 5000], + }, + { + frame: pad([ + " __ __ ", + " ( ) ( ) ", + " .------. ", + " /(T) (T)\\ ", + "| , ω , | ", + " \\ /‾‾\\ / ", + " '------' ", + ], 16), + duration: 1500, + }, + ], + loop: true, + }, + ], + }, +} diff --git a/core/pets/turtle.ts b/core/pets/turtle.ts new file mode 100644 index 0000000..8b542c7 --- /dev/null +++ b/core/pets/turtle.ts @@ -0,0 +1,255 @@ +import type { PetState, PetAnimations } from "../types" +import { pad } from "../frame-utils" + +// A 2.5D, full-body turtle seen from a three-quarter top angle: a small head +// with eyes pokes out the top, a domed patterned carapace (rim lines + plates +// give the shell depth), front flippers on the sides, two back legs and a tail +// at the bottom. The idle state layers a blinking `eyes` track over a static +// `body`; every other state is a single 2-frame base layer. WIDTH = 16. + +export const turtleAnim: PetAnimations = { + states: { + idle: [ + { + id: "body", + steps: [ + { + frame: pad([ + " ___ ", + " _/ \\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 4000, + }, + ], + loop: true, + }, + { + id: "eyes", + steps: [ + { + frame: pad([ + " ", + " o o ", + " ", + " ", + " ", + " ", + " ", + ], 16), + durationRange: [2000, 4000], + }, + { + frame: pad([ + " ", + " - - ", + " ", + " ", + " ", + " ", + " ", + ], 16), + duration: 130, + }, + ], + loop: true, + }, + ], + happy: [ + { + id: "base", + steps: [ + { + frame: pad([ + " ___ ", + " _/^ ^\\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + durationRange: [1500, 2500], + }, + { + frame: pad([ + " * ___ * ", + " _/^ ^\\_ ", + " / \\_/ \\ ", + " |♥/ --- \\♥| ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 700, + }, + ], + loop: true, + }, + ], + sleeping: [ + { + id: "base", + steps: [ + { + frame: pad([ + " ___ z ", + " _/- -\\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ \\_\\ ", + ], 16), + durationRange: [2000, 3500], + }, + { + frame: pad([ + " ___ z ", + " _/- -\\_ Z ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ \\_\\ ", + ], 16), + duration: 1200, + }, + ], + loop: true, + }, + ], + eating: [ + { + id: "base", + steps: [ + { + frame: pad([ + " ___ ", + " _/o o\\_ ", + " / \\nom/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 400, + }, + { + frame: pad([ + " ___ ", + " _/^ ^\\_ ", + " / \\o/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 350, + }, + ], + loop: true, + }, + ], + playing: [ + { + id: "base", + steps: [ + { + frame: pad([ + " ___ ", + " _/> <\\_ ", + " / \\_/ \\ ", + "~| / --- \\ |~ ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 500, + }, + { + frame: pad([ + " ___ ", + " _/< >\\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " ~\\ ----- /~ ", + " //_/ v \\_\\\\ ", + ], 16), + duration: 500, + }, + ], + loop: true, + }, + ], + excited: [ + { + id: "base", + steps: [ + { + frame: pad([ + " * _*_ * ", + " _/* *\\_ ", + " / \\!/ \\ ", + " |♥/ --- \\♥| ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ v \\_\\ ", + ], 16), + duration: 280, + }, + { + frame: pad([ + " ___ ", + " _/^ ^\\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " //_/ v \\_\\\\ ", + ], 16), + duration: 280, + }, + ], + loop: true, + }, + ], + sad: [ + { + id: "base", + steps: [ + { + frame: pad([ + " ___ ", + " _/T T\\_ ", + " / \\_/ \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ \\_\\ ", + ], 16), + durationRange: [3000, 5000], + }, + { + frame: pad([ + " ___ ", + " _/T T\\_ ", + " / ,\\_/, \\ ", + " | / --- \\ | ", + " | |[+][+]| | ", + " \\ ----- / ", + " /_/ \\_\\ ", + ], 16), + duration: 1500, + }, + ], + loop: true, + }, + ], + }, +} diff --git a/core/render.ts b/core/render.ts index c537d85..6dfccfb 100644 --- a/core/render.ts +++ b/core/render.ts @@ -14,6 +14,10 @@ const MINI_FACE: Record = { hamster: ["(\\_/)", "(='.'=)"], ghost: ["(^-^)ノ⌒", "(~^-^)~"], robot: ["[o_o]", "[-_-]"], + dragon: ["(/\\o o/\\)", "(/\\^ ^/\\)"], + turtle: ["(@ o o @)", "(@ - - @)"], + panda: ["(@^.^@)", "(@^ω^@)"], + dog: ["(o-|-o)", "(^-|-^)"], } export const miniFace = (pet: string, frame = 0): string => { diff --git a/core/theme.ts b/core/theme.ts index 3f797cd..42d91a2 100644 --- a/core/theme.ts +++ b/core/theme.ts @@ -25,6 +25,10 @@ export const PET_ICONS: Record = { hamster: "🐹", ghost: "👻", robot: "🤖", + dragon: "🐉", + turtle: "🐢", + panda: "🐼", + dog: "🐶", } // Per-pet accent colors used by the GIF player. @@ -32,6 +36,10 @@ export const PET_COLORS: Record = { cat: "#bd93f9", ghost: "#f1fa8c", robot: "#50fa7b", + dragon: "#ff5555", + turtle: "#50fa7b", + panda: "#f8f8f2", + dog: "#ffb86c", } // 24-bit ANSI foreground escape for a "#rrggbb" hex color, for terminal surfaces. diff --git a/docs/superpowers/specs/2026-05-03-cursor-pets-design.md b/docs/superpowers/specs/2026-05-03-cursor-pets-design.md new file mode 100644 index 0000000..c619d64 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-cursor-pets-design.md @@ -0,0 +1,603 @@ +# Design: Cursor IDE Pets Extension + +**Date:** 2026-05-03 +**Status:** Draft — awaiting review + +## Overview + +Port the campy ASCII pets plugin to Cursor IDE as a VS Code extension. Cursor is built on VS Code and uses the VS Code Extension API, so this is a standard VS Code extension that renders animated ASCII pets (cat, hamster, ghost, robot) in a sidebar WebView panel. The extension loads animation frame data from the existing `assets/ascii-frames/*.json` files, runs the same multi-layer animation engine, displays speech bubbles, and reacts to coding events (file saves, diagnostics, terminal execution). + +### Feature Parity + +| Feature | OpenCode Plugin | Cursor Extension | Approach | +|---------|----------------|------------------|----------| +| Animated ASCII pets | Solid.js sidebar | WebView sidebar | `
` tag + setInterval |
+| 7 pet states | ✓ | ✓ | Same frame data, rewritten AnimationEngine |
+| Speech bubbles | ✓ | ✓ | CSS-styled speech bubble in WebView |
+| Event reactions | 6 OpenCode events | 4 VS Code events | Map to equivalent VS Code API |
+| Happiness system | ✓ | ✓ | Same scoring, persisted via `context.globalState` |
+| Slash commands | 9 commands | 9 commands | Registered via `vscode.commands` |
+| Pet switching | ✓ | ✓ | Command → postMessage to WebView |
+| 4 pet types | ✓ | ✓ | Load `cat/hamster/ghost/robot.json` |
+
+### What's Different
+
+- **Rendering**: OpenCode uses Solid.js + custom ``/`` components; Cursor uses an HTML WebView with vanilla JS or a lightweight framework (Preact ~3 KB). The WebView is a sandboxed iframe.
+- **Event API**: OpenCode `api.event.on(...)` → VS Code `vscode.workspace.onDidSaveTextDocument(...)`, etc.
+- **Sidebar panel**: Registered as a `WebviewView` via `package.json#contributes.views` so it appears in Cursor's sidebar.
+- **Commands**: Registered via `package.json#contributes.commands` rather than a dynamic `api.command.register` call.
+- **Persistence**: Happiness and pet type saved to `ExtensionContext.globalState` instead of Solid.js signals.
+
+## Architecture
+
+### Extension Activation & Lifecycle
+
+```
+Cursor boots
+  → package.json#activationEvents: ["onView:campyPets.petPanel"]
+  → extension.ts#activate() runs
+    → Loads frame JSON from bundled assets/
+    → Registers WebviewViewProvider for "campyPets.petPanel"
+    → Registers all commands (pet.feed, pet.play, etc.)
+    → Registers event listeners (onDidSaveTextDocument, onDidChangeDiagnostics, etc.)
+  → User opens sidebar → WebView created → HTML loaded → AnimationEngine starts
+```
+
+### Component Diagram
+
+```
+┌───────────────────────────────────────────────────────────┐
+│  Cursor Extension Host (Node.js)                          │
+│                                                           │
+│  extension.ts                                             │
+│  ├── activate(context)                                    │
+│  │   ├── loadFrameData(assets/ascii-frames/*.json)        │
+│  │   ├── registerWebviewViewProvider('campyPets.petPanel')│
+│  │   │   └── PetPanelProvider                             │
+│  │   ├── registerCommand('pet.feed', ...)                 │
+│  │   ├── registerCommand('pet.play', ...)                 │
+│  │   ├── registerCommand('pet.cat', ...)   ← 9 commands   │
+│  │   ├── listenToEvents()                                 │
+│  │   │   ├── onDidSaveTextDocument → send 'file-saved'    │
+│  │   │   ├── onDidChangeDiagnostics → send 'error'        │
+│  │   │   ├── window.onDidWriteTerminalData → send 'cmd'   │
+│  │   │   └── onDidChangeTextDocument → send 'editing'     │
+│  │   └── context.subscriptions.push(...)                  │
+│  └── deactivate()                                         │
+│                                                           │
+│  src/                                                     │
+│  ├── provider.ts      — WebviewViewProvider impl           │
+│  ├── engine.ts        — AnimationEngine (port)             │
+│  ├── animations.ts    — Hardcoded pet animations (same as plugin) │
+│  ├── frames.ts        — Frame data loader + types         │
+│  └── constants.ts     — Colors, phrases, defaults         │
+│                                                           │
+│  media/                                                   │
+│  ├── webview.html     — Shipped with extension            │
+│  ├── webview.js       — WebView runtime (AnimationEngine) │
+│  └── webview.css      — Styling for pets panel            │
+└───────────────────────────────────────────────────────────┘
+         │  postMessage (JSON)  │
+         ▼                      ▼
+┌───────────────────────────────────────────────────────────┐
+│  WebView (sandboxed iframe)                               │
+│                                                           │
+│  
│ +│
Edited file.ts!
│ +│
        │
+│   /\_/\                                            │
+│  ( o.o )                                         ...     │
+│   > ^ <                                                  │
+│    
│ +│
🐱 cat happy 😊
│ +│
│ +│ │ +│ Receives: set-pet, set-state, show-speech, set-happiness │ +│ Sends: pet-ready (on init) │ +└───────────────────────────────────────────────────────────┘ +``` + +### WebViewProvider Pattern + +Use `WebviewViewProvider` (not `WebviewPanel`) so the pet panel appears as a persistent sidebar view: + +```typescript +// provider.ts +class PetPanelProvider implements vscode.WebviewViewProvider { + private _view?: vscode.WebviewView; + + resolveWebviewView(webviewView: vscode.WebviewView) { + this._view = webviewView; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [/* media/ directory */], + }; + webviewView.webview.html = this._getHtml(webviewView.webview); + webviewView.webview.onDidReceiveMessage(msg => this._handleMessage(msg)); + } + + postMessage(msg: PetMessage) { + this._view?.webview.postMessage(msg); + } +} +``` + +## WebView Rendering + +### HTML Structure + +The WebView hosts a single HTML page with minimal CSS and vanilla JS (no npm deps, no build step — or optionally Preact via CDN for reactive state). The rendering surface is a `
` tag with monospace font. Each line of the ASCII sprite is a `` or plain text row colored via inline `color` style.
+
+```html
+

+```
+
+### Color Application
+
+Use `PET_COLORS` (per-state colors from the existing plugin):
+
+```typescript
+const PET_COLORS: Record = {
+  idle:     "#bd93f9",  // purple
+  happy:    "#50fa7b",  // green
+  sleeping: "#6272a4",  // dim blue
+  eating:   "#ffb86c",  // orange
+  playing:  "#ff79c6",  // pink
+  excited:  "#f1fa8c",  // yellow
+  sad:      "#ff5555",  // red
+};
+```
+
+Each frame render wraps ASCII lines in a single `` block:
+
+```javascript
+function renderFrame(frames, color) {
+  const html = frames.map(line => escapeHtml(line)).join('\n');
+  petArt.innerHTML = `${html}`;
+}
+```
+
+### Speech Bubble
+
+Rendered as a CSS-styled `
` above the pet. Speech bubble appears/disappears based on messages from the extension: + +```css +.speech-bubble { + position: relative; + background: #f8f8f2; + color: #282a36; + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + max-width: 220px; + opacity: 0; + transition: opacity 0.3s; +} +.speech-bubble.visible { opacity: 1; } +.speech-bubble::after { + content: ''; + position: absolute; + bottom: -6px; + left: 20px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #f8f8f2; +} +``` + +### Animation Loop + +The WebView-side `AnimationEngine` runs via `setInterval`/`requestAnimationFrame`: + +``` +1. Extension posts: { type: 'set-state', state: 'happy' } +2. WebView receives → AnimationEngine.switchState('happy') +3. Engine composites layers, picks current frame +4. Render via requestAnimationFrame (target 3 FPS) +5. Engine auto-cycles states via random timeout (10-30s) +6. Each frame rendered with matching color from PET_COLORS +``` + +## Data Flow + +### Frame Data Format + +The same JSON format from `assets/ascii-frames/*.json`: + +```json +{ + "idle": { + "frames": [ + [" /\\_/\\ ", " ( o.o ) ", " > ^ < "], + [" /\\_/\\ ", " ( -.- ) ", " > ^ < "] + ], + "durations": [2000, 150] + }, + "happy": { "frames": [...], "durations": [...] } +} +``` + +These JSON files are bundled into the extension (imported as TypeScript objects or loaded at runtime via `fs.readFileSync`). They total ~40 KB across all 4 pets. + +### Extension → WebView Messages + +All messages use `webview.postMessage(msg)` with a typed message protocol: + +```typescript +type ExtensionMessage = + | { type: 'init'; payload: { pet: string; state: PetState; happiness: number; frames: PetFrameData; animations: PetAnimations } } + | { type: 'set-state'; payload: { state: PetState; speech?: string; duration?: number } } + | { type: 'set-pet'; payload: { pet: string; frames: PetFrameData; animations: PetAnimations } } + | { type: 'set-happiness'; payload: { happiness: number } } + | { type: 'show-speech'; payload: { text: string; duration: number } }; +``` + +On `init`, the extension sends the full frame data and hardcoded animation definitions to the WebView. The WebView stores them and runs the animation engine locally — no further frame data round-trips needed. + +### WebView → Extension Messages + +```typescript +type WebViewMessage = + | { type: 'ready' } + | { type: 'log'; payload: { level: string; text: string } }; +``` + +### State Persistence + +Happiness score and selected pet type persist via `ExtensionContext.globalState`: + +```typescript +// On activate +const savedPet = context.globalState.get('campyPet', 'cat'); +const savedHappiness = context.globalState.get('campyHappiness', 80); + +// On change +context.globalState.update('campyPet', petType); +context.globalState.update('campyHappiness', happiness); +``` + +This survives Cursor restarts and extension reloads. + +## Event Reactions + +Map OpenCode events to VS Code API equivalents: + +| OpenCode Event | VS Code API | Trigger | Pet Reaction | +|---------------|-------------|---------|--------------| +| `file.edited` | `workspace.onDidSaveTextDocument` | File saved | State: eating + "Edited {filename}!" | +| `session.error` | `languages.onDidChangeDiagnostics` | Error diagnostic appears | State: sad + "Error! Let me help..." | +| `command.executed` | `window.onDidWriteTerminalData` | Terminal output detected | State: happy + "Ran task!" | +| `message.part.delta` | — | _(Cursor AI streaming)_ | State: excited + "Thinking..." | +| `session.idle` | `setTimeout` in WebView | No activity for ~30s | State: idle + random phrase | +| `tui.prompt.append` | — | _(no equivalent)_ | — | + +### Implementation Notes + +**File Save** — fires on `onDidSaveTextDocument`. Debounce 500ms to handle rapid saves. + +```typescript +vscode.workspace.onDidSaveTextDocument(doc => { + const filename = doc.fileName.split('/').pop() || doc.fileName; + sendToWebView({ type: 'override-state', payload: { state: 'eating', duration: 4000, speech: `Edited ${filename}!` } }); + changeHappiness(2); +}); +``` + +**Diagnostics** — fires on `onDidChangeDiagnostics`. Only trigger on new errors (not warnings or info). + +```typescript +vscode.languages.onDidChangeDiagnostics(event => { + const hasNewError = event.uris.some(uri => { + const diags = vscode.languages.getDiagnostics(uri); + return diags.some(d => d.severity === vscode.DiagnosticSeverity.Error); + }); + if (hasNewError) { + sendToWebView({ type: 'override-state', payload: { state: 'sad', duration: 5000, speech: 'Error! Let me help...' } }); + changeHappiness(-5); + } +}); +``` + +**Terminal Output** — fires on `window.onDidWriteTerminalData`. Trigger happy state when a command finishes (detect prompt character `$` or `>`): + +```typescript +vscode.window.onDidWriteTerminalData(e => { + if (e.data.includes('$ ') || e.data.includes('> ')) { + sendToWebView({ type: 'set-state', payload: { state: 'happy', speech: 'Done!' } }); + changeHappiness(3); + } +}); +``` + +**Idle Detection** — handled in the WebView JavaScript. If no message received from extension for 30 seconds, auto-transition to idle state. The extension resets a timestamp on each activity postMessage. + +**Cursor AI Events** (optional) — Cursor may expose AI streaming events through its own API. If unavailable, skip the `message.part.delta` equivalent. A future iteration could detect `onDidChangeTextDocument` activity in a Cursor Chat file. + +## Commands + +Registered in `package.json#contributes.commands` and implemented in `extension.ts`: + +```json +{ + "contributes": { + "commands": [ + { "command": "campyPets.feed", "title": "Campy: Feed Pet" }, + { "command": "campyPets.play", "title": "Campy: Play with Pet" }, + { "command": "campyPets.pet", "title": "Campy: Pet Pet" }, + { "command": "campyPets.sleep", "title": "Campy: Put Pet to Sleep" }, + { "command": "campyPets.wake", "title": "Campy: Wake Pet" }, + { "command": "campyPets.status", "title": "Campy: Pet Status" }, + { "command": "campyPets.switchCat", "title": "Campy: Switch to Cat" }, + { "command": "campyPets.switchHamster", "title": "Campy: Switch to Hamster" }, + { "command": "campyPets.switchGhost", "title": "Campy: Switch to Ghost" }, + { "command": "campyPets.switchRobot", "title": "Campy: Switch to Robot" } + ] + } +} +``` + +Command palette shows them as `Campy: Feed Pet`, `Campy: Play with Pet`, etc. + +### Command Behaviors + +| Command | Happiness | State | Duration | Speech | +|---------|-----------|-------|----------|--------| +| feed | +15 | eating | 5000ms | "Nom nom!" | +| play | +20 | playing | 5000ms | "Wheee!" | +| pet | +10 | happy | 3000ms | "Purr..." | +| sleep | — | sleeping | indefinite | "Zzz..." | +| wake | — | idle | — | "I'm awake!" | +| status | — | — | — | Toast: "🐱 cat | happy | 80%" | +| switch pet | reset to 80 | idle | — | Greeting phrase | + +### Status Bar Item + +Optionally contribute a status bar item showing the current pet emoji and happiness: + +``` +🐱 80% ────── +``` + +Registered via `vscode.window.createStatusBarItem` in `activate()`. Clicking it reveals the pet panel. + +## File Structure + +``` +cursor-pets/ +├── .vscode/ +│ ├── launch.json # Debug config for extension host +│ └── tasks.json # Build tasks +├── assets/ +│ └── ascii-frames/ # Copied from campy/assets/ascii-frames/ +│ ├── cat.json # 8 frames, ~4 KB +│ ├── hamster.json # 1 frame, ~0.5 KB +│ ├── ghost.json # 5 frames, ~2 KB +│ └── robot.json # 61 frames, ~15 KB +├── media/ +│ ├── webview.html # WebView HTML shell +│ ├── webview.js # WebView runtime (AnimationEngine port) +│ └── webview.css # Pet panel styling +├── src/ +│ ├── extension.ts # activate/deactivate entry point +│ ├── provider.ts # WebviewViewProvider implementation +│ ├── animations.ts # Hardcoded pet animation definitions +│ ├── constants.ts # PET_COLORS, phrases, personality text +│ ├── engine.ts # AnimationEngine (Node.js side — unused in WebView but available for testing) +│ ├── frames.ts # Frame data loader: reads assets/*.json +│ └── types.ts # TypeScript types (PetState, Message, etc.) +├── package.json # Extension manifest +├── tsconfig.json # TypeScript config +├── .vscodeignore # Files to exclude from VSIX +└── README.md # Extension documentation +``` + +### Key Files Detail + +**package.json** - Extension manifest: + +```json +{ + "name": "cursor-pets", + "displayName": "Campy — ASCII Pets for Cursor", + "version": "1.0.0", + "publisher": "campy", + "engines": { "vscode": "^1.82.0" }, + "activationEvents": ["onView:campyPets.petPanel"], + "main": "./out/extension.js", + "contributes": { + "views": { + "campyPets": [ + { + "id": "campyPets.petPanel", + "name": "Campy Pets", + "type": "webview", + "icon": "$(hubot)" + } + ] + }, + "commands": [ + { "command": "campyPets.feed", "title": "Campy: Feed Pet" }, + { "command": "campyPets.play", "title": "Campy: Play with Pet" }, + { "command": "campyPets.pet", "title": "Campy: Pet Pet" }, + { "command": "campyPets.sleep", "title": "Campy: Put Pet to Sleep" }, + { "command": "campyPets.wake", "title": "Campy: Wake Pet" }, + { "command": "campyPets.status", "title": "Campy: Pet Status" }, + { "command": "campyPets.switchCat", "title": "Campy: Switch to Cat" }, + { "command": "campyPets.switchHamster", "title": "Campy: Switch to Hamster" }, + { "command": "campyPets.switchGhost", "title": "Campy: Switch to Ghost" }, + { "command": "campyPets.switchRobot", "title": "Campy: Switch to Robot" } + ] + }, + "scripts": { + "vscode:prepublish": "tsc -p ./", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/vscode": "^1.82.0", + "typescript": "^5.3.0" + } +} +``` + +**extension.ts** - Activation entry: + +```typescript +import * as vscode from 'vscode'; +import { PetPanelProvider } from './provider'; +import { loadFrameData } from './frames'; +import { COMMANDS } from './constants'; + +export function activate(context: vscode.ExtensionContext) { + // Load frame data at startup + const frames = loadFrameData(context.extensionPath); + + // Register webview provider + const provider = new PetPanelProvider(context.extensionUri, frames, context); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('campyPets.petPanel', provider) + ); + + // Register commands + for (const cmd of COMMANDS) { + context.subscriptions.push( + vscode.commands.registerCommand(cmd.id, () => provider.handleCommand(cmd.id)) + ); + } + + // Event listeners + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument(doc => + provider.onFileSaved(doc) + ), + vscode.languages.onDidChangeDiagnostics(e => + provider.onDiagnostics(e) + ), + vscode.window.onDidWriteTerminalData(e => + provider.onTerminalOutput(e) + ) + ); + + // Status bar item + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, 100 + ); + statusBarItem.command = 'campyPets.petPanel.focus'; + statusBarItem.text = `$(hubot) ${provider.statusText}`; + statusBarItem.show(); + context.subscriptions.push(statusBarItem); +} + +export function deactivate() {} +``` + +**webview.js** — The WebView-side AnimationEngine. A self-contained JS file with no external dependencies: + +```javascript +// webview.js — runs inside the WebView iframe +(function() { + const vscode = acquireVsCodeApi(); + + let currentPet = 'cat'; + let currentState = 'idle'; + let happiness = 80; + let engine = null; + let frameData = {}; + let animData = {}; + let idleTimer = null; + + // AnimationEngine — same compositing logic as the OpenCode plugin + class AnimationEngine { + constructor(animations, onRender) { /* ... */ } + resetToState(state) { /* composite layers, render */ } + setState(state) { /* trigger transition anim */ } + destroy() { /* clear timers */ } + } + + function renderFrame(lines, color) { + const html = lines.map(l => escapeHtml(l)).join('\n'); + document.getElementById('pet-art').innerHTML = + `${html}`; + } + + function showSpeech(text, duration) { + const bubble = document.getElementById('speech-bubble'); + bubble.textContent = text; + bubble.classList.add('visible'); + clearTimeout(bubble._timeout); + bubble._timeout = setTimeout(() => bubble.classList.remove('visible'), duration); + } + + window.addEventListener('message', event => { + const msg = event.data; + switch (msg.type) { + case 'init': + currentPet = msg.payload.pet; + happiness = msg.payload.happiness; + frameData = msg.payload.frames; + animData = msg.payload.animations; + engine = new AnimationEngine(animData[currentPet], renderFrame); + engine.resetToState('idle'); + updateStatusBar(); + break; + case 'set-state': + engine?.setState(msg.payload.state); + if (msg.payload.speech) showSpeech(msg.payload.speech, msg.payload.duration || 4000); + break; + case 'set-happiness': + happiness = msg.payload.happiness; + updateStatusBar(); + break; + case 'show-speech': + showSpeech(msg.payload.text, msg.payload.duration); + break; + } + }); + + vscode.postMessage({ type: 'ready' }); +})(); +``` + +### Color Theme Support + +The WebView respects the current VS Code color theme. CSS variables from `body.vscode-dark` / `body.vscode-light` set background/text colors. The pet ASCII colors use the fixed `PET_COLORS` palette (which are Dracula-theme-friendly and work on both light and dark backgrounds). + +## Dependencies + +### Runtime (bundled) + +- **@types/vscode** (dev only) — Type definitions for VS Code Extension API +- **typescript** (dev only) — Compiles `src/` → `out/` +- **No runtime npm dependencies** — the extension uses only the VS Code API (`vscode` module) + +### Conceptual Dependencies + +- **VS Code Extension API** (`vscode` module): + - `window.registerWebviewViewProvider` — sidebar WebView panel + - `workspace.onDidSaveTextDocument` — file save events + - `languages.onDidChangeDiagnostics` — error detection + - `window.onDidWriteTerminalData` — terminal output + - `commands.registerCommand` — command palette commands + - `ExtensionContext.globalState` — persistence +- **WebView API**: + - `acquireVsCodeApi()` — message passing bridge + - `postMessage` / `onmessage` — extension↔webview communication + +### VSIX Packaging + +The extension ships as a `.vsix` file containing: +- `out/extension.js` (compiled TypeScript) +- `assets/ascii-frames/*.json` (frame data) +- `media/webview.html`, `media/webview.js`, `media/webview.css` +- `package.json`, `README.md` + +Total package size estimated at ~35 KB (gzipped). No node_modules shipped. diff --git a/tests/core.test.ts b/tests/core.test.ts index 8a872e7..fb3a972 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -7,6 +7,10 @@ import { reactionFor, isCanonicalEvent } from "../core/events" import { clamp, meterBar, HAPPINESS_MIN, HAPPINESS_MAX, DEFAULT_HAPPINESS } from "../core/happiness" import { readState, writeState, mutateState, DEFAULT_STATE } from "../core/store" import type { PetStoreState } from "../core/store" +import { PET_ANIMATIONS, PET_NAMES } from "../core/pets" +import { PET_ICONS } from "../core/theme" +import { PET_GREETINGS, PET_PERSONALITY } from "../core/personality" +import { STATES } from "../core/types" // ---- events ---------------------------------------------------------------- @@ -241,3 +245,49 @@ describe("store round-trip", () => { expect(result.happiness).toBe(HAPPINESS_MAX) }) }) + +// ---- new pets registered --------------------------------------------------- + +describe("new pets registered", () => { + const NEW_PETS = ["dragon", "turtle", "panda", "dog"] + + test("all four new pets are in PET_NAMES and PET_ANIMATIONS", () => { + expect(PET_NAMES.length).toBe(8) + for (const pet of NEW_PETS) { + expect(PET_NAMES).toContain(pet) + expect(PET_ANIMATIONS[pet]).toBeTruthy() + } + }) + + test("each new pet has all 7 PetState keys in PET_ANIMATIONS with non-empty AnimLayer[]", () => { + for (const pet of NEW_PETS) { + for (const s of STATES) { + const layers = PET_ANIMATIONS[pet]?.states[s] + expect(layers).toBeTruthy() + expect(layers!.length).toBeGreaterThan(0) + } + } + }) + + test("each new pet has a truthy PET_ICONS entry", () => { + for (const pet of NEW_PETS) { + expect(PET_ICONS[pet]).toBeTruthy() + } + }) + + test("each new pet has a truthy PET_GREETINGS entry", () => { + for (const pet of NEW_PETS) { + expect(PET_GREETINGS[pet]).toBeTruthy() + } + }) + + test("each new pet has PET_PERSONALITY entries for all 7 states with non-empty string[]", () => { + for (const pet of NEW_PETS) { + for (const s of STATES) { + const phrases = PET_PERSONALITY[pet]?.[s] + expect(phrases).toBeTruthy() + expect(phrases!.length).toBeGreaterThan(0) + } + } + }) +}) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index d6ce717..c7857bc 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -49,7 +49,7 @@ test("campy_switch to a valid pet updates the store", () => { }) test("campy_switch to an unknown pet is an error result", () => { - const res = call("campy_switch", { pet: "dragon" }) + const res = call("campy_switch", { pet: "unicorn" }) expect(res.result.isError).toBe(true) })