/** * Pi Pet v3 — Tamagotchi extension with animated sprites, behavior-based evolution, * rarity system, and overlay menu. * * 18 species ported from Claude Code's buddy system, merged with our XP/mood progression. * Egg hatches into a species based on your coding behavior. * * Shortcut: Ctrl+; — Pet action menu (Feed, Pet, Stats, Rename, Toggle) * Command: /pet — Toggle widget, /pet to rename */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; const PET_FILE = join(homedir(), ".pi", "agent", "pet-state.json"); // ============================================================================= // Types // ============================================================================= const SPECIES = [ "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin", "turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot", "rabbit", "mushroom", "chonk", ] as const; type Species = (typeof SPECIES)[number]; const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"] as const; type Rarity = (typeof RARITIES)[number]; const EYES: string[] = ["·", "✦", "×", "◉", "@", "°"]; const HATS: string[] = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"]; const RARITY_WEIGHTS: Record = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }; const RARITY_STARS: Record = { common: "★", uncommon: "★★", rare: "★★★", epic: "★★★★", legendary: "★★★★★" }; const RARITY_THEME: Record = { common: "dim", uncommon: "success", rare: "accent", epic: "warning", legendary: "error" }; const HAT_AVAIL: Record = { common: ["none", "beanie"], uncommon: ["none", "beanie", "propeller"], rare: ["none", "beanie", "propeller", "tophat", "wizard"], epic: ["none", "beanie", "propeller", "tophat", "wizard", "crown", "halo"], legendary: HATS, }; const SAVE_TYPE = "pi-pet-v3"; interface PetState { version: 3; name: string; species: Species | null; eye: string; hat: string; rarity: Rarity; shiny: boolean; xp: number; level: number; mood: number; turns: number; toolCalls: number; bashCalls: number; editCalls: number; readCalls: number; writeCalls: number; nightCalls: number; createdAt: number; lastFed: number; hatchedAt: number | null; rerollCount: number; pity: number; commitsWitnessed: number; } const CUTE_NAMES = [ "Mochi", "Nori", "Pixel", "Bit", "Dash", "Fern", "Pebble", "Sprout", "Bean", "Pip", "Fizz", "Rune", "Spark", "Dusk", "Luna", "Echo", "Sage", "Tofu", ]; // ============================================================================= // Sprites — ported from Claude Code's buddy system // ============================================================================= const E = "{E}"; const BODIES: Record = { duck: [ [" ", " __ ", ` <(${E} )___ `, " ( ._> ", " `--´ "], [" ", " __ ", ` <(${E} )___ `, " ( ._> ", " `--´~ "], [" ", " __ ", ` <(${E} )___ `, " ( .__> ", " `--´ "], ], goose: [ [" ", ` (${E}> `, " || ", " _(__)_ ", " ^^^^ "], [" ", ` (${E}> `, " || ", " _(__)_ ", " ^^^^ "], [" ", ` (${E}>> `, " || ", " _(__)_ ", " ^^^^ "], ], blob: [ [" ", " .----. ", ` ( ${E} ${E} ) `, " ( ) ", " `----´ "], [" ", " .------. ", ` ( ${E} ${E} ) `, " ( ) ", " `------´ "], [" ", " .--. ", ` (${E} ${E}) `, " ( ) ", " `--´ "], ], cat: [ [" ", " /\\_/\\ ", ` ( ${E} ${E}) `, " ( ω ) ", ' (")_(") '], [" ", " /\\_/\\ ", ` ( ${E} ${E}) `, " ( ω ) ", ' (")_(")~ '], [" ", " /\\-/\\ ", ` ( ${E} ${E}) `, " ( ω ) ", ' (")_(") '], ], dragon: [ [" ", " /^\\ /^\\ ", ` < ${E} ${E} > `, " ( ~~ ) ", " `-vvvv-´ "], [" ", " /^\\ /^\\ ", ` < ${E} ${E} > `, " ( ) ", " `-vvvv-´ "], [" ~ ~ ", " /^\\ /^\\ ", ` < ${E} ${E} > `, " ( ~~ ) ", " `-vvvv-´ "], ], octopus: [ [" ", " .----. ", ` ( ${E} ${E} ) `, " (______) ", " /\\/\\/\\/\\ "], [" ", " .----. ", ` ( ${E} ${E} ) `, " (______) ", " \\/\\/\\/\\/ "], [" o ", " .----. ", ` ( ${E} ${E} ) `, " (______) ", " /\\/\\/\\/\\ "], ], owl: [ [" ", " /\\ /\\ ", ` ((${E})(${E})) `, " ( >< ) ", " `----´ "], [" ", " /\\ /\\ ", ` ((${E})(${E})) `, " ( >< ) ", " .----. "], [" ", " /\\ /\\ ", ` ((${E})(-)) `, " ( >< ) ", " `----´ "], ], penguin: [ [" ", " .---. ", ` (${E}>${E}) `, " /( )\\ ", " `---´ "], [" ", " .---. ", ` (${E}>${E}) `, " |( )| ", " `---´ "], [" .---. ", ` (${E}>${E}) `, " /( )\\ ", " `---´ ", " ~ ~ "], ], turtle: [ [" ", " _,--._ ", ` ( ${E} ${E} ) `, " /[______]\\ ", " `` `` "], [" ", " _,--._ ", ` ( ${E} ${E} ) `, " /[______]\\ ", " `` `` "], [" ", " _,--._ ", ` ( ${E} ${E} ) `, " /[======]\\ ", " `` `` "], ], snail: [ [" ", ` ${E} .--. `, " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~~ "], [" ", ` ${E} .--. `, " | ( @ ) ", " \\_`--´ ", " ~~~~~~~ "], [" ", ` ${E} .--. `, " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~ "], ], ghost: [ [" ", " .----. ", ` / ${E} ${E} \\ `, " | | ", " ~`~``~`~ "], [" ", " .----. ", ` / ${E} ${E} \\ `, " | | ", " `~`~~`~` "], [" ~ ~ ", " .----. ", ` / ${E} ${E} \\ `, " | | ", " ~~`~~`~~ "], ], axolotl: [ [" ", "}~(______)~{", `}~(${E} .. ${E})~{`, " ( .--. ) ", " (_/ \\_) "], [" ", "~}(______){~", `~}(${E} .. ${E}){~`, " ( .--. ) ", " (_/ \\_) "], [" ", "}~(______)~{", `}~(${E} .. ${E})~{`, " ( -- ) ", " ~_/ \\_~ "], ], capybara: [ [" ", " n______n ", ` ( ${E} ${E} ) `, " ( oo ) ", " `------´ "], [" ", " n______n ", ` ( ${E} ${E} ) `, " ( Oo ) ", " `------´ "], [" ~ ~ ", " u______n ", ` ( ${E} ${E} ) `, " ( oo ) ", " `------´ "], ], cactus: [ [" ", " n ____ n ", ` | |${E} ${E}| | `, " |_| |_| ", " | | "], [" ", " ____ ", ` n |${E} ${E}| n `, " |_| |_| ", " | | "], [" n n ", " | ____ | ", ` | |${E} ${E}| | `, " |_| |_| ", " | | "], ], robot: [ [" ", " .[||]. ", ` [ ${E} ${E} ] `, " [ ==== ] ", " `------´ "], [" ", " .[||]. ", ` [ ${E} ${E} ] `, " [ -==- ] ", " `------´ "], [" * ", " .[||]. ", ` [ ${E} ${E} ] `, " [ ==== ] ", " `------´ "], ], rabbit: [ [" ", " (\\__/) ", ` ( ${E} ${E} ) `, " =( .. )= ", ' (")__(") '], [" ", " (|__/) ", ` ( ${E} ${E} ) `, " =( .. )= ", ' (")__(") '], [" ", " (\\__/) ", ` ( ${E} ${E} ) `, " =( . . )= ", ' (")__(") '], ], mushroom: [ [" ", " .-o-OO-o-. ", "(__________)", ` |${E} ${E}| `, " |____| "], [" ", " .-O-oo-O-. ", "(__________)", ` |${E} ${E}| `, " |____| "], [" . o . ", " .-o-OO-o-. ", "(__________)", ` |${E} ${E}| `, " |____| "], ], chonk: [ [" ", " /\\ /\\ ", ` ( ${E} ${E} ) `, " ( .. ) ", " `------´ "], [" ", " /\\ /| ", ` ( ${E} ${E} ) `, " ( .. ) ", " `------´ "], [" ", " /\\ /\\ ", ` ( ${E} ${E} ) `, " ( .. ) ", " `------´~ "], ], }; const HAT_LINES: Record = { none: "", crown: " \\^^^/ ", tophat: " [___] ", propeller: " -+- ", halo: " ( ) ", wizard: " /^\\ ", beanie: " (___) ", tinyduck: " ,> ", }; const EGG_FRAMES: string[][] = [ [" ___ ", " / \\ ", " | ~~~ | ", " | ___ | ", " \\___/ "], [" ___ ", " / \\ ", " | ~~~ | ", " | * | ", " \\___/ "], [" ___ ", " / * \\ ", " | ~~~ | ", " | ___ | ", " \\___/ "], ]; const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; const HEART_FRAMES = [" ♡ ♡ ", " ♡ ♡ ♡ ", "♡ ♡ ", " ♡ ♡ ", "· · "]; // ============================================================================= // Sprite Rendering // ============================================================================= function renderSprite(species: Species | null, eye: string, hat: string, frame: number, blink: boolean): string[] { if (!species) { return EGG_FRAMES[Math.abs(frame) % EGG_FRAMES.length]; } const frames = BODIES[species]; const body = frames[Math.abs(frame) % frames.length].map((line: string) => { let l = line.replaceAll("{E}", blink ? "-" : eye); return l; }); const lines = [...body]; if (hat !== "none" && !lines[0].trim()) { lines[0] = HAT_LINES[hat] || lines[0]; } return lines; } // ============================================================================= // Pet Mechanics // ============================================================================= function createPet(): PetState { return { version: 3, name: "Egg", species: null, eye: "·", hat: "none", rarity: "common", shiny: false, xp: 0, level: 1, mood: 80, turns: 0, toolCalls: 0, bashCalls: 0, editCalls: 0, readCalls: 0, writeCalls: 0, nightCalls: 0, createdAt: Date.now(), lastFed: Date.now(), hatchedAt: null, rerollCount: 0, pity: 0, commitsWitnessed: 0, }; } function xpForLevel(level: number): number { return Math.floor(10 + 5 * level); } function gainXP(pet: PetState, amount: number): boolean { pet.xp += amount; pet.lastFed = Date.now(); pet.mood = Math.min(100, pet.mood + 2); let leveled = false; while (pet.xp >= xpForLevel(pet.level)) { pet.xp -= xpForLevel(pet.level); pet.level++; leveled = true; pet.mood = 100; } return leveled; } function decayMood(pet: PetState): void { const hours = (Date.now() - pet.lastFed) / 3600000; if (hours > 1) pet.mood = Math.max(0, pet.mood - Math.floor(hours * 5)); } function pick(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } function rarityIndex(r: Rarity): number { return RARITIES.indexOf(r); } function rollRarity(toolCalls: number, pity: number = 0): Rarity { const boost = Math.min(toolCalls / 100, 1); const weights = { ...RARITY_WEIGHTS }; weights.common = Math.max(5, weights.common - boost * 40 - pity * 10); weights.uncommon += boost * 15; weights.rare += boost * 10 + pity * 5; weights.epic += boost * 10 + pity * 3; weights.legendary += boost * 5 + pity * 2; const total = Object.values(weights).reduce((a, b) => a + b, 0); let roll = Math.random() * total; for (const r of RARITIES) { roll -= weights[r]; if (roll < 0) return r; } return "common"; } function determineSpecies(pet: PetState, exclude?: Species): Species { const total = Math.max(1, pet.toolCalls); const bashR = pet.bashCalls / total; const editR = (pet.editCalls + pet.writeCalls) / total; const readR = pet.readCalls / total; const hour = new Date().getHours(); const isNight = hour < 6 || hour >= 22; const nightR = pet.nightCalls / total; let pool: Species[]; if (nightR > 0.4 || isNight) { pool = ["ghost", "owl", "axolotl"]; } else if (bashR > 0.5) { pool = ["dragon", "robot", "goose"]; } else if (editR > 0.4) { pool = ["cat", "rabbit", "cactus"]; } else if (readR > 0.4) { pool = ["owl", "mushroom", "turtle"]; } else if (pet.toolCalls > 40) { pool = ["octopus", "axolotl", "snail"]; } else { pool = ["blob", "capybara", "duck", "penguin", "chonk"]; } if (exclude) { const filtered = pool.filter(s => s !== exclude); if (filtered.length > 0) pool = filtered; } return pick(pool); } function hatchPet(pet: PetState): void { pet.species = determineSpecies(pet); pet.rarity = rollRarity(pet.toolCalls, pet.pity); pet.eye = pick(EYES); pet.hat = pick(HAT_AVAIL[pet.rarity]); pet.shiny = Math.random() < 0.01; pet.hatchedAt = Date.now(); if (pet.name === "Egg") pet.name = pick(CUTE_NAMES); } function rerollPet(pet: PetState): { upgraded: boolean } { const oldRarity = pet.rarity; const oldSpecies = pet.species; pet.species = determineSpecies(pet, oldSpecies ?? undefined); pet.rarity = rollRarity(pet.toolCalls, pet.pity); pet.eye = pick(EYES); pet.hat = pick(HAT_AVAIL[pet.rarity]); pet.shiny = Math.random() < 0.01; pet.hatchedAt = Date.now(); pet.name = pick(CUTE_NAMES); pet.level = 3; pet.xp = 0; pet.rerollCount++; const upgraded = rarityIndex(pet.rarity) > rarityIndex(oldRarity); if (upgraded) { pet.pity = 0; } else { pet.pity++; } return { upgraded }; } function getStageName(level: number): string { if (level < 3) return "Egg"; if (level < 8) return "Baby"; if (level < 15) return "Juvenile"; if (level < 25) return "Adult"; if (level < 40) return "Elder"; return "Mythic"; } function getAge(createdAt: number): string { const ms = Date.now() - createdAt; const mins = Math.floor(ms / 60000); const hours = Math.floor(mins / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d`; if (hours > 0) return `${hours}h`; if (mins > 0) return `${mins}m`; return "newborn"; } function getMoodEmoji(mood: number): string { if (mood >= 80) return "♡"; if (mood >= 60) return "◡"; if (mood >= 40) return "~"; if (mood >= 20) return "…"; return "☁"; } function migrateV2(data: any): PetState { return { version: 3, name: data.name || "Mochi", species: null, eye: "·", hat: "none", rarity: "common", shiny: false, xp: data.xp || 0, level: data.level || 1, mood: data.mood || 80, turns: data.turns || 0, toolCalls: data.toolCalls || 0, bashCalls: 0, editCalls: 0, readCalls: 0, writeCalls: 0, nightCalls: 0, createdAt: data.createdAt || Date.now(), lastFed: data.lastFed || Date.now(), hatchedAt: null, rerollCount: 0, pity: 0, commitsWitnessed: 0, }; } // ============================================================================= // Animation State // ============================================================================= let animTick = 0; let tuiRef: any = null; let animTimer: ReturnType | null = null; let pettingUntil = 0; let isStreaming = false; let lastStreamTime = 0; function getAnimFrame(): { frame: number; blink: boolean } { const petting = Date.now() < pettingUntil; if (petting || isStreaming) { return { frame: animTick % 3, blink: false }; } const step = IDLE_SEQUENCE[animTick % IDLE_SEQUENCE.length]; if (step === -1) return { frame: 0, blink: true }; return { frame: step, blink: false }; } // ============================================================================= // Widget Rendering // ============================================================================= function renderPetWidget(theme: any, pet: PetState): string[] { const { frame, blink } = getAnimFrame(); const sprite = renderSprite(pet.species, pet.eye, pet.hat, frame, blink); const petting = Date.now() < pettingUntil; const dim = (s: string) => theme.fg("dim", s); const accent = (s: string) => theme.fg("accent", s); const bold = (s: string) => theme.bold(s); const muted = (s: string) => theme.fg("muted", s); const success = (s: string) => theme.fg("success", s); const needed = xpForLevel(pet.level); const barLen = 10; const filled = Math.round((pet.xp / needed) * barLen); const xpBar = "█".repeat(filled) + "░".repeat(barLen - filled); const age = getAge(pet.createdAt); const moodEmoji = getMoodEmoji(pet.mood); const stage = getStageName(pet.level); const rarityColor = RARITY_THEME[pet.rarity]; const stars = pet.species ? theme.fg(rarityColor, RARITY_STARS[pet.rarity]) : ""; const shinyMark = pet.shiny ? theme.fg("warning", " ✧") : ""; const speciesLabel = pet.species ? `${pet.species}` : "egg"; const infoLines: string[] = [ accent(bold(pet.name)) + dim(` ${moodEmoji}`) + shinyMark, dim("LV.") + accent(bold(`${pet.level}`)) + dim(` ${speciesLabel} ${stage}`) + (stars ? ` ${stars}` : ""), success(xpBar) + dim(` ${pet.xp}/${needed}`), dim(`${pet.toolCalls} tools · ${pet.turns} turns · ${age}`), pet.commitsWitnessed > 0 ? dim(`${pet.name} watched you ship `) + accent(`${pet.commitsWitnessed}`) + dim(` commit${pet.commitsWitnessed === 1 ? "" : "s"}`) : dim(`mood ${pet.mood}%`) + (pet.hat !== "none" ? dim(` · ${pet.hat}`) : ""), ]; const lines: string[] = []; if (petting) { const hf = HEART_FRAMES[animTick % HEART_FRAMES.length]; lines.push(theme.fg("error", hf) + " " + (infoLines[0] || "")); } const spriteStart = petting ? 1 : 0; const maxLines = Math.max(sprite.length, infoLines.length - spriteStart); for (let i = 0; i < maxLines; i++) { const sp = i < sprite.length ? sprite[i] : " "; const infoIdx = i + spriteStart; const info = infoIdx < infoLines.length ? infoLines[infoIdx] : ""; lines.push(`${muted(sp)} ${info}`); } return lines; } // ============================================================================= // Overlay Menu // ============================================================================= class PetMenu { private items: { label: string; icon: string; action: string }[]; private selected = 0; private theme: any; private done: (value: string | null) => void; private petName: string; private cachedLines: string[] | undefined; private cachedWidth: number | undefined; constructor(theme: any, done: (value: string | null) => void, petName: string) { this.theme = theme; this.done = done; this.petName = petName; this.items = [ { label: "Feed", icon: "🍙", action: "feed" }, { label: "Pet", icon: "♡", action: "pet" }, { label: "Stats", icon: "📊", action: "stats" }, { label: "Rename", icon: "✏", action: "rename" }, { label: "Reroll", icon: "🎲", action: "reroll" }, { label: "Toggle", icon: "👁", action: "toggle" }, ]; } handleInput(data: string): void { if (matchesKey(data, Key.up) && this.selected > 0) { this.selected--; this.invalidate(); } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) { this.selected++; this.invalidate(); } else if (matchesKey(data, Key.enter)) { this.done(this.items[this.selected].action); } else if (matchesKey(data, Key.escape) || data === "q") { this.done(null); } } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const t = this.theme; const lines: string[] = []; lines.push(t.fg("accent", t.bold(`── 🐾 ${this.petName} ──`))); lines.push(""); for (let i = 0; i < this.items.length; i++) { const item = this.items[i]; const prefix = i === this.selected ? t.fg("accent", "▸ ") : " "; const label = i === this.selected ? t.bold(item.label) : t.fg("muted", item.label); lines.push(truncateToWidth(`${prefix}${label} ${item.icon}`, width)); } lines.push(""); lines.push(t.fg("dim", "↑↓ navigate · enter select · esc close")); this.cachedLines = lines; this.cachedWidth = width; return lines; } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; } } // ============================================================================= // Extension // ============================================================================= const COUNTER_KEYS = ["toolCalls", "bashCalls", "editCalls", "readCalls", "writeCalls", "nightCalls", "turns", "commitsWitnessed"] as const; function readPetFile(): PetState | null { try { const data = JSON.parse(readFileSync(PET_FILE, "utf-8")); if (data && data.version === 3) { data.rerollCount ??= 0; data.pity ??= 0; data.commitsWitnessed ??= 0; return data as PetState; } if (data) { const migrated = migrateV2(data); if (migrated.level >= 3 && !migrated.species) hatchPet(migrated); return migrated; } } catch {} return null; } function writePetFile(state: PetState): void { try { mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true }); writeFileSync(PET_FILE, JSON.stringify(state), "utf-8"); } catch {} } function gitHead(): string | null { try { return execSync("git rev-parse HEAD", { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; } } function gitCountSince(base: string): number { try { return parseInt(execSync(`git rev-list ${base}..HEAD --count`, { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim(), 10) || 0; } catch { return 0; } } export default function (pi: ExtensionAPI) { let pet: PetState = createPet(); let widgetOn = true; let currentCtx: ExtensionContext | null = null; let sessionBaseHead: string | null = null; let sessionCommits = 0; // Delta tracking for concurrent session safety. // Instead of overwriting the file with our in-memory state, // we accumulate deltas and merge them into whatever the file has. let deltas = { toolCalls: 0, bashCalls: 0, editCalls: 0, readCalls: 0, writeCalls: 0, nightCalls: 0, turns: 0, xpGained: 0, commitsWitnessed: 0 }; function resetDeltas() { deltas = { toolCalls: 0, bashCalls: 0, editCalls: 0, readCalls: 0, writeCalls: 0, nightCalls: 0, turns: 0, xpGained: 0, commitsWitnessed: 0 }; } // ── State restoration ── function restorePet(ctx: ExtensionContext) { const fromFile = readPetFile(); if (fromFile) { pet = fromFile; return; } // Fallback: session entries (legacy) const entries = ctx.sessionManager.getEntries(); for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; if (e.type === "custom") { if (e.customType === SAVE_TYPE) { pet = e.data as PetState; writePetFile(pet); return; } if (e.customType === "pi-pet-v2") { pet = migrateV2(e.data); if (pet.level >= 3 && !pet.species) hatchPet(pet); writePetFile(pet); return; } } } } // Read file, apply our deltas, write back. Both sessions' progress accumulates. function save() { const fresh = readPetFile() || pet; // Merge counters: file value + our deltas since last save for (const k of COUNTER_KEYS) { (fresh as any)[k] += (deltas as any)[k] || 0; } // Apply XP deltas and recompute level if (deltas.xpGained > 0) { fresh.xp += deltas.xpGained; while (fresh.xp >= xpForLevel(fresh.level)) { fresh.xp -= xpForLevel(fresh.level); fresh.level++; } } // Non-counter fields: take our local value (mood, name, species, cosmetics) fresh.mood = pet.mood; fresh.lastFed = Math.max(fresh.lastFed, pet.lastFed); fresh.name = pet.name; if (pet.species && !fresh.species) { fresh.species = pet.species; fresh.eye = pet.eye; fresh.hat = pet.hat; fresh.rarity = pet.rarity; fresh.shiny = pet.shiny; fresh.hatchedAt = pet.hatchedAt; } resetDeltas(); pet = fresh; pi.appendEntry(SAVE_TYPE, { ...pet }); writePetFile(pet); } // ── Widget management ── function installWidget(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setWidget("pi-pet", (tui: any, theme: any) => { tuiRef = tui; return { invalidate() {}, render(): string[] { return renderPetWidget(theme, pet); }, }; }, { placement: "aboveEditor" as any }); } function removeWidget(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setWidget("pi-pet", undefined); } function startAnimLoop() { if (animTimer) return; animTimer = setInterval(() => { animTick++; if (isStreaming && Date.now() - lastStreamTime > 3000) { isStreaming = false; } if (widgetOn && tuiRef) { try { tuiRef.requestRender(); } catch {} } }, 500); } function stopAnimLoop() { if (animTimer) { clearInterval(animTimer); animTimer = null; } } // ── Action handlers ── function doFeed(ctx: ExtensionContext) { pet.mood = Math.min(100, pet.mood + 20); pet.lastFed = Date.now(); save(); ctx.ui.notify(`🍙 ${pet.name} is happy! Mood: ${pet.mood}%`, "info"); } function doPet(ctx: ExtensionContext) { pettingUntil = Date.now() + 2500; pet.mood = Math.min(100, pet.mood + 10); save(); ctx.ui.notify(`♡ ${pet.name} loves the attention!`, "info"); } function doStats(ctx: ExtensionContext) { const stage = getStageName(pet.level); const age = getAge(pet.createdAt); const needed = xpForLevel(pet.level); const species = pet.species || "egg"; const stars = pet.species ? ` ${RARITY_STARS[pet.rarity]}` : ""; const shiny = pet.shiny ? " ✧SHINY" : ""; const statLines = [ `${pet.name} — Lv.${pet.level} ${species} ${stage}${stars}${shiny}`, `XP: ${pet.xp}/${needed} · Mood: ${pet.mood}% · Age: ${age}`, `Eye: ${pet.eye} · Hat: ${pet.hat} · Rarity: ${pet.rarity}`, `Tools: ${pet.toolCalls} (bash:${pet.bashCalls} edit:${pet.editCalls} read:${pet.readCalls} write:${pet.writeCalls})`, `Turns: ${pet.turns} · Night calls: ${pet.nightCalls}`, `Rerolls: ${pet.rerollCount} · Pity: ${pet.pity}`, `Commits witnessed: ${pet.commitsWitnessed}`, ]; ctx.ui.notify(statLines.join("\n"), "info"); } async function doRename(ctx: ExtensionContext) { const name = await ctx.ui.input("Rename your pet", `Current: ${pet.name}`); if (name && name.trim()) { pet.name = name.trim(); save(); ctx.ui.notify(`✨ Renamed to ${pet.name}!`, "info"); } } function doToggle(ctx: ExtensionContext) { widgetOn = !widgetOn; if (widgetOn) { installWidget(ctx); ctx.ui.notify(`🐾 ${pet.name} appeared!`, "info"); } else { removeWidget(ctx); ctx.ui.notify(`🐾 ${pet.name} went to sleep`, "info"); } } function doReroll(ctx: ExtensionContext) { if (!pet.species) { ctx.ui.notify(`Your egg hasn't hatched yet!`, "info"); return; } if (pet.level < 10) { ctx.ui.notify(`Need Lv.10+ to reroll (currently Lv.${pet.level}). Keep coding!`, "info"); return; } const oldSpecies = pet.species; const oldRarity = pet.rarity; const { upgraded } = rerollPet(pet); save(); const stars = RARITY_STARS[pet.rarity]; const shiny = pet.shiny ? " ✧SHINY✧" : ""; const pityMsg = !upgraded && pet.pity > 0 ? `\nPity: ${pet.pity} (better odds next time!)` : ""; ctx.ui.notify( `🎲 Rerolled! ${oldRarity} ${oldSpecies} → ${pet.rarity} ${pet.species}${shiny}\n` + `${stars} Meet ${pet.name}! Eye: ${pet.eye} Hat: ${pet.hat}\n` + `Level reset to 3. Rerolls: ${pet.rerollCount}${pityMsg}`, "info", ); if (widgetOn) installWidget(ctx); } // ── Events ── pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; restorePet(ctx); decayMood(pet); sessionBaseHead = gitHead(); if (widgetOn && ctx.hasUI) installWidget(ctx); startAnimLoop(); }); pi.on("turn_start", async () => { pet.turns++; deltas.turns++; }); pi.on("message_update", async (event) => { const evt = event.assistantMessageEvent; if (evt && (evt.type === "text_delta" || evt.type === "thinking_delta")) { isStreaming = true; lastStreamTime = Date.now(); } }); pi.on("tool_call", async (event) => { const name = event.toolName; if (name === "bash") { pet.bashCalls++; deltas.bashCalls++; } else if (name === "edit") { pet.editCalls++; deltas.editCalls++; } else if (name === "read") { pet.readCalls++; deltas.readCalls++; } else if (name === "write") { pet.writeCalls++; deltas.writeCalls++; } const hour = new Date().getHours(); if (hour < 6 || hour >= 22) { pet.nightCalls++; deltas.nightCalls++; } }); pi.on("tool_execution_end", async (_event, ctx) => { pet.toolCalls++; deltas.toolCalls++; const xpGain = 3 + Math.floor(Math.random() * 5); deltas.xpGained += xpGain; const leveled = gainXP(pet, xpGain); if (sessionBaseHead) { const newCommits = gitCountSince(sessionBaseHead); if (newCommits > sessionCommits) { const diff = newCommits - sessionCommits; pet.commitsWitnessed += diff; deltas.commitsWitnessed = (deltas.commitsWitnessed || 0) + diff; sessionCommits = newCommits; } } save(); if (!pet.species && pet.level >= 3) { hatchPet(pet); save(); const stars = RARITY_STARS[pet.rarity]; const shiny = pet.shiny ? " ✧SHINY✧" : ""; ctx.ui.notify( `🥚→🐣 Your egg hatched into a ${pet.rarity} ${pet.species}!${shiny}\n` + `${stars} Meet ${pet.name}! Eye: ${pet.eye} Hat: ${pet.hat}`, "info", ); if (widgetOn) installWidget(ctx); } else if (leveled) { const stage = getStageName(pet.level); ctx.ui.notify(`🎉 ${pet.name} reached Lv.${pet.level}! ${stage}`, "info"); } }); pi.on("session_shutdown", async () => { save(); stopAnimLoop(); }); // ── Shortcut: Ctrl+; ── pi.registerShortcut("ctrl+;", { description: "Pet action menu", handler: async (ctx) => { if (!ctx.hasUI) return; const action = await ctx.ui.custom( (_tui, theme, _kb, done) => new PetMenu(theme, done, pet.name), { overlay: true, overlayOptions: { anchor: "bottom-right" as any, width: 40, margin: 1 } }, ); if (!action) return; switch (action) { case "feed": doFeed(ctx); break; case "pet": doPet(ctx); break; case "stats": doStats(ctx); break; case "rename": await doRename(ctx); break; case "reroll": doReroll(ctx); break; case "toggle": doToggle(ctx); break; } }, }); // ── /pet command (fallback) ── pi.registerCommand("pet", { description: "Pet: /pet, /pet , /pet feed|pet|stats|hatch|reroll", handler: async (args, ctx) => { const cmd = args?.trim().toLowerCase(); if (cmd === "feed") return doFeed(ctx); if (cmd === "pet") return doPet(ctx); if (cmd === "stats") return doStats(ctx); if (cmd === "reroll") return doReroll(ctx); if (cmd === "hatch") { if (pet.species) { ctx.ui.notify(`${pet.name} already hatched! They're a ${pet.species}.`, "info"); return; } if (pet.level < 3) { ctx.ui.notify(`Egg needs to be Lv.3+ to hatch (currently Lv.${pet.level})`, "info"); return; } hatchPet(pet); save(); const stars = RARITY_STARS[pet.rarity]; const shiny = pet.shiny ? " ✧SHINY✧" : ""; ctx.ui.notify( `🥚→🐣 Hatched into a ${pet.rarity} ${pet.species}!${shiny}\n${stars} Meet ${pet.name}!`, "info", ); if (widgetOn) installWidget(ctx); return; } if (args && args.trim() && !["feed", "pet", "stats", "hatch"].includes(cmd || "")) { pet.name = args.trim(); save(); ctx.ui.notify(`✨ Renamed to ${pet.name}!`, "info"); return; } doToggle(ctx); }, }); }