Initial Commit

This commit is contained in:
2026-04-12 20:41:43 -07:00
commit 4cd0d8c589
30 changed files with 934 additions and 0 deletions

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# HyperPets
Animated pets for [Hyper](https://hyper.is) terminal panes.
The first pass includes:
- one or two pets per pane
- idle, wander, sleep, cursor-chase, and toy-chase behavior
- bounded toy spawning with cooldowns
- lightweight per-pane overlay rendering
- external cat sprite frames with state transitions between major behaviors
## Install locally during development
1. Put this folder somewhere stable.
2. Add `hyperpets` to `localPlugins` in your Hyper config.
```js
module.exports = {
config: {
hyperPets: {
petCount: 1,
petTypes: ['cat'],
maxToys: 1,
toyCooldownMs: 20000,
successToyChance: 0.35,
walkSpeed: 0.65
}
},
localPlugins: [
'hyperpets'
]
};
```
On macOS the config file is typically:
`~/Library/Application Support/Hyper/.hyper.js`
Then reload Hyper with `Cmd+R`.
## Config
```js
hyperPets: {
enabled: true,
petCount: 1,
petTypes: ['cat'],
maxToys: 1,
toyCooldownMs: 20000,
successToyChance: 0.35,
chaseCursorChance: 0.04,
walkSpeed: 0.65,
reducedMotion: false,
onlyActivePane: true
}
```
## Notes
- Command-success toy spawning currently uses a prompt-return heuristic from terminal output, not shell integration.
- The plugin is intentionally conservative so it does not flood panes with toys or constant motion.
- The current animation system uses asset manifests under `assets/pets/`, with only `cat` shipped right now. The loader is generic so additional pet types can be added later without changing behavior logic.
## Asset Pipeline
- Cat frames live in `assets/pets/cat/frames/*.svg`.
- The cat manifest in `assets/pets/cat.js` maps clips and transitions to those frame files.
- The renderer reads the SVG files once at startup and swaps them by animation clip at runtime.
- To add a new pet type later, create another manifest under `assets/pets/` plus a matching frame directory, then register it in `assets/pets/index.js`.

99
assets/pets/cat.js Normal file
View File

@@ -0,0 +1,99 @@
'use strict';
const fs = require('fs');
const path = require('path');
function loadSvg(name) {
return fs.readFileSync(path.join(__dirname, 'cat', 'frames', `${name}.svg`), 'utf8');
}
function frame(name) {
return {
name,
svg: loadSvg(name)
};
}
module.exports = {
width: 48,
height: 32,
clips: {
idle: {
fps: 3,
frames: [
frame('idle-1'),
frame('idle-2'),
frame('idle-3')
]
},
walk: {
fps: 10,
frames: [
frame('walk-1'),
frame('walk-2'),
frame('walk-3'),
frame('walk-4')
]
},
inspect: {
fps: 5,
frames: [
frame('inspect-1'),
frame('inspect-2'),
frame('inspect-3')
]
},
chase: {
fps: 12,
frames: [
frame('chase-1'),
frame('chase-2'),
frame('chase-3'),
frame('chase-4')
]
},
sleep: {
fps: 2,
frames: [
frame('sleep-1'),
frame('sleep-2')
]
},
transitionIdleWalk: {
fps: 10,
frames: [
frame('transition-idle-walk-1'),
frame('transition-idle-walk-2'),
frame('transition-idle-walk-3')
],
nextClip: 'walk'
},
transitionWalkSleep: {
fps: 9,
frames: [
frame('transition-walk-sleep-1'),
frame('transition-walk-sleep-2'),
frame('transition-walk-sleep-3')
],
nextClip: 'sleep'
},
transitionSleepIdle: {
fps: 8,
frames: [
frame('transition-sleep-idle-1'),
frame('transition-sleep-idle-2'),
frame('transition-sleep-idle-3')
],
nextClip: 'idle'
}
},
transitions: {
'idle->walk': 'transitionIdleWalk',
'inspect->walk': 'transitionIdleWalk',
'walk->sleep': 'transitionWalkSleep',
'chase->sleep': 'transitionWalkSleep',
'sleep->idle': 'transitionSleepIdle',
'sleep->walk': 'transitionSleepIdle',
'sleep->inspect': 'transitionSleepIdle'
}
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 18 Q8 15 7 13 Q6 10 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(10 12 18)"/><rect x="16" y="28" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="20" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11.2" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(2 33 14)"/><path d="M39 12 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-2 37 14)"/><ellipse cx="34" cy="16" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="18.8" r="1.1" fill="#6a4c39"/><path d="M35.2 20 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.1" fill="rgba(0,0,0,0.14)"/><path d="M12 17.5 Q8 14.5 7 12.5 Q6 9.5 9 8" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(16 12 17.5)"/><rect x="16" y="26" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="22" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="13.5" width="22" height="11.5" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(4 33 14)"/><path d="M39 12 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-4 37 14)"/><ellipse cx="34" cy="16" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="18.8" r="1.1" fill="#6a4c39"/><path d="M35.2 20 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 18 Q8 15 7 13 Q6 10 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(20 12 18)"/><rect x="16" y="20" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="28" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11.2" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-1 33 14)"/><path d="M39 12 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(1 37 14)"/><ellipse cx="34" cy="16" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="18.8" r="1.1" fill="#6a4c39"/><path d="M35.2 20 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(12 12 19)"/><rect x="16" y="22" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="26" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="16" width="22" height="10.9" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-14 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 18.5 Q8 15.5 7 13.5 Q6 10.5 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-10 12 18.5)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="14.5" width="22" height="11.2" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(1 33 15)"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-1 37 15)"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-16 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18.5 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13.5 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-1 33 15)"/><path d="M39 13.5 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(1 37 15)"/><ellipse cx="34" cy="17.5" rx="1" ry="1.1" fill="#1d140f"/><ellipse cx="38" cy="17.5" rx="1" ry="1.1" fill="#1d140f"/><circle cx="36" cy="20.3" r="1.1" fill="#6a4c39"/><path d="M35.2 21.5 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-8 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(2 33 14)"/><path d="M39 12 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-2 37 14)"/><ellipse cx="34" cy="16" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="18.8" r="1.1" fill="#6a4c39"/><path d="M35.2 20 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 18.5 Q8 15.5 7 13.5 Q6 10.5 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-2 12 18.5)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="14.5" width="22" height="11.1" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 16 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 11 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(4 33 13)"/><path d="M39 11 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-4 37 13)"/><ellipse cx="34" cy="15" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="15" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="17.8" r="1.1" fill="#6a4c39"/><path d="M35.2 19 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-8 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(2 33 14)"/><path d="M39 12 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-2 37 14)"/><ellipse cx="34" cy="16" rx="1" ry="1.1" fill="#1d140f"/><ellipse cx="38" cy="16" rx="1" ry="1.1" fill="#1d140f"/><circle cx="36" cy="18.8" r="1.1" fill="#6a4c39"/><path d="M35.2 20 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12" ry="3.8" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-18 12 20)"/><rect x="11" y="16" width="24" height="12" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M29 21 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 16 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-4 32 18)"/><path d="M38 16 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(4 36 18)"/><ellipse cx="33" cy="20" rx="1" ry="0.7" fill="#1d140f"/><ellipse cx="37" cy="20" rx="1" ry="0.7" fill="#1d140f"/><circle cx="35" cy="22.8" r="1.1" fill="#6a4c39"/><path d="M34.2 24 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12" ry="3.6" fill="rgba(0,0,0,0.14)"/><path d="M12 21 Q8 18 7 16 Q6 13 9 11" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-20 12 21)"/><rect x="11" y="17" width="24" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M29 22 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-5 32 19)"/><path d="M38 17 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(5 36 19)"/><ellipse cx="33" cy="21" rx="1" ry="0.7" fill="#1d140f"/><ellipse cx="37" cy="21" rx="1" ry="0.7" fill="#1d140f"/><circle cx="35" cy="23.8" r="1.1" fill="#6a4c39"/><path d="M34.2 25 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 929 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-10 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.3" fill="rgba(0,0,0,0.14)"/><path d="M12 18.5 Q8 15.5 7 13.5 Q6 10.5 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(0 12 18.5)"/><rect x="16" y="25" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="23" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="14.5" width="22" height="11.1" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17.5 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12.5 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(1 33 14.5)"/><path d="M39 12.5 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-1 37 14.5)"/><ellipse cx="34" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.3" r="1.1" fill="#6a4c39"/><path d="M35.2 20.5 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(8 12 19)"/><rect x="16" y="27" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="21" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11.2" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17.5 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12.5 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 12.5 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.3" r="1.1" fill="#6a4c39"/><path d="M35.2 20.5 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12" ry="3.8" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-18 12 20)"/><rect x="11" y="16" width="24" height="12" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M29 21 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 16 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-4 32 18)"/><path d="M38 16 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(4 36 18)"/><ellipse cx="33" cy="20" rx="1" ry="0.7" fill="#1d140f"/><ellipse cx="37" cy="20" rx="1" ry="0.7" fill="#1d140f"/><circle cx="35" cy="22.8" r="1.1" fill="#6a4c39"/><path d="M34.2 24 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12.5" ry="4" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-12 12 20)"/><rect x="11" y="16" width="22" height="10.5" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 19 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 14 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-1 33 16)"/><path d="M39 14 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(1 37 16)"/><ellipse cx="34" cy="18" rx="1" ry="1.1" fill="#1d140f"/><ellipse cx="38" cy="18" rx="1" ry="1.1" fill="#1d140f"/><circle cx="36" cy="20.8" r="1.1" fill="#6a4c39"/><path d="M35.2 22 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-12 12 19)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(4 12 19)"/><rect x="16" y="22" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="26" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1011 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12.5" ry="4" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-8 12 20)"/><rect x="16" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="24" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="16" width="22" height="10.5" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 19 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 14 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-2 33 16)"/><path d="M39 14 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(2 37 16)"/><ellipse cx="34" cy="18" rx="1" ry="1.1" fill="#1d140f"/><ellipse cx="38" cy="18" rx="1" ry="1.1" fill="#1d140f"/><circle cx="36" cy="20.8" r="1.1" fill="#6a4c39"/><path d="M35.2 22 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="12" ry="3.8" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-18 12 20)"/><rect x="11" y="16" width="24" height="12" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M29 21 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 16 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-4 32 18)"/><path d="M38 16 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(4 36 18)"/><ellipse cx="33" cy="20" rx="1" ry="0.7" fill="#1d140f"/><ellipse cx="37" cy="20" rx="1" ry="0.7" fill="#1d140f"/><circle cx="35" cy="22.8" r="1.1" fill="#6a4c39"/><path d="M34.2 24 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(-4 12 19)"/><rect x="16" y="27" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="21" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 18 Q8 15 7 13 Q6 10 9 8.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(6 12 18)"/><rect x="16" y="25" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="23" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="14" width="22" height="11.2" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 17.5 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 12.5 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(1 33 14.5)"/><path d="M39 12.5 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(-1 37 14.5)"/><ellipse cx="34" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="16.5" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.3" r="1.1" fill="#6a4c39"/><path d="M35.2 20.5 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.4" fill="rgba(0,0,0,0.14)"/><path d="M12 19 Q8 16 7 14 Q6 11 9 9.5" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(12 12 19)"/><rect x="16" y="21" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="27" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="15" width="22" height="11" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13 l2 -5 l3 4" fill="#9d7a5d" transform="rotate(-1 33 15)"/><path d="M39 13 l-2 -5 l-3 4" fill="#9d7a5d" transform="rotate(1 37 15)"/><ellipse cx="34" cy="17" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="19.8" r="1.1" fill="#6a4c39"/><path d="M35.2 21 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32"><ellipse cx="23" cy="28.5" rx="13" ry="4.2" fill="rgba(0,0,0,0.14)"/><path d="M12 20 Q8 17 7 15 Q6 12 9 10" fill="none" stroke="#6d5240" stroke-width="3.5" stroke-linecap="round" transform="rotate(2 12 20)"/><rect x="16" y="23" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="27" y="25" width="3.2" height="5.5" rx="1.2" fill="#6d5240"/><rect x="11" y="16" width="22" height="10.8" rx="6" fill="#9d7a5d" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M30 18.5 q0 -6 6 -6 q6 0 6 6 q0 6 -6 6 q-6 0 -6 -6 z" fill="#f1dbc1" stroke="rgba(30,18,10,0.2)" stroke-width="0.8"/><path d="M31 13.5 l2 -5 l3 4" fill="#9d7a5d"/><path d="M39 13.5 l-2 -5 l-3 4" fill="#9d7a5d"/><ellipse cx="34" cy="17.5" rx="1" ry="2.2" fill="#1d140f"/><ellipse cx="38" cy="17.5" rx="1" ry="2.2" fill="#1d140f"/><circle cx="36" cy="20.3" r="1.1" fill="#6a4c39"/><path d="M35.2 21.5 q0.8 1 1.8 0" fill="none" stroke="#6a4c39" stroke-width="0.8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

21
assets/pets/index.js Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
const cat = require('./cat');
const PET_ASSETS = {
cat
};
function getPetAsset(type) {
return PET_ASSETS[type] || PET_ASSETS.cat;
}
function getAvailablePetTypes() {
return Object.keys(PET_ASSETS);
}
module.exports = {
PET_ASSETS,
getPetAsset,
getAvailablePetTypes
};

706
index.js Normal file
View File

@@ -0,0 +1,706 @@
'use strict';
const { getPetAsset, getAvailablePetTypes } = require('./assets/pets');
const DEFAULT_CONFIG = {
enabled: true,
petCount: 1,
petTypes: ['cat'],
maxToys: 1,
toyCooldownMs: 20000,
successToyChance: 0.35,
chaseCursorChance: 0.04,
walkSpeed: 0.65,
reducedMotion: false,
onlyActivePane: true
};
const listenersByUid = new Map();
const sessionStateByUid = new Map();
let runtimeConfig = DEFAULT_CONFIG;
const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g;
const ERROR_RE = /\b(command not found|not recognized|no such file|error|exception|failed|traceback)\b/i;
const PROMPT_RE = /(?:^|\n)[^\n]{0,80}(?:\$|%|>||➜|λ)\s?$/;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeConfig(input) {
const next = Object.assign({}, DEFAULT_CONFIG, input || {});
const availablePetTypes = getAvailablePetTypes();
next.petCount = clamp(Number(next.petCount) || 1, 1, 2);
next.maxToys = clamp(Number(next.maxToys) || 1, 0, 3);
next.toyCooldownMs = Math.max(Number(next.toyCooldownMs) || DEFAULT_CONFIG.toyCooldownMs, 3000);
next.successToyChance = clamp(Number(next.successToyChance), 0, 1);
next.chaseCursorChance = clamp(Number(next.chaseCursorChance), 0, 1);
next.walkSpeed = Math.max(Number(next.walkSpeed) || DEFAULT_CONFIG.walkSpeed, 0.2);
next.petTypes = Array.isArray(next.petTypes) && next.petTypes.length ? next.petTypes : DEFAULT_CONFIG.petTypes;
next.petTypes = next.petTypes.filter((type) => availablePetTypes.includes(type));
if (next.petTypes.length === 0) {
next.petTypes = DEFAULT_CONFIG.petTypes.slice();
}
return next;
}
function modeToClip(mode) {
if (mode === 'sleep') {
return 'sleep';
}
if (mode === 'inspect') {
return 'inspect';
}
if (mode === 'chase-cursor' || mode === 'chase-toy') {
return 'chase';
}
if (mode === 'walk') {
return 'walk';
}
return 'idle';
}
function getClipDuration(petType, clipName, reducedMotion) {
const petAsset = getPetAsset(petType);
const clip = petAsset.clips[clipName];
if (!clip) {
return 1000;
}
const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps;
return (clip.frames.length / fps) * 1000;
}
function resolveAnimationState(pet, desiredClip, now, reducedMotion) {
const petAsset = getPetAsset(pet.type);
const currentClip = pet.clip || desiredClip;
const activeClip = petAsset.clips[currentClip] ? currentClip : desiredClip;
const activeDef = petAsset.clips[activeClip];
const elapsed = now - (pet.clipStartedAt || now);
const activeDuration = getClipDuration(pet.type, activeClip, reducedMotion);
if (activeDef && activeDef.nextClip && elapsed >= activeDuration) {
const nextClip = pet.transitionTo || activeDef.nextClip;
return {
clip: nextClip,
clipStartedAt: now,
transitionTo: null
};
}
if (activeClip === desiredClip) {
return {
clip: activeClip,
clipStartedAt: pet.clipStartedAt || now,
transitionTo: null
};
}
const transitionClip = petAsset.transitions[`${activeClip}->${desiredClip}`] || null;
if (transitionClip) {
return {
clip: transitionClip,
clipStartedAt: now,
transitionTo: desiredClip
};
}
return {
clip: desiredClip,
clipStartedAt: now,
transitionTo: null
};
}
function getAnimatedFrame(pet, reducedMotion, now) {
const petAsset = getPetAsset(pet.type);
const clipName = pet.clip || 'idle';
const clip = petAsset.clips[clipName] || petAsset.clips.idle;
const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps;
const duration = 1000 / fps;
const elapsed = now - (pet.clipStartedAt || now);
const rawIndex = clip.frames.length === 1 ? 0 : Math.floor(elapsed / duration);
const safeIndex = ((rawIndex % clip.frames.length) + clip.frames.length) % clip.frames.length;
return clip.frames[safeIndex] || petAsset.clips.idle.frames[0];
}
function subscribe(uid, listener) {
if (!uid) {
return () => {};
}
if (!listenersByUid.has(uid)) {
listenersByUid.set(uid, new Set());
}
const listeners = listenersByUid.get(uid);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
listenersByUid.delete(uid);
}
};
}
function emit(uid, event) {
const listeners = listenersByUid.get(uid);
if (!listeners) {
return;
}
listeners.forEach((listener) => {
try {
listener(event);
} catch (error) {
console.error('[hyperpets] listener error', error);
}
});
}
function stripAnsi(value) {
return String(value || '').replace(ANSI_RE, '');
}
function inspectSessionData(uid, data) {
const clean = stripAnsi(data);
const state = sessionStateByUid.get(uid) || {
tail: '',
promptSeen: false,
lastPrompt: '',
sawError: false,
lastToyAt: 0
};
state.tail = (state.tail + clean).slice(-500);
if (ERROR_RE.test(clean)) {
state.sawError = true;
emit(uid, { type: 'error-output' });
}
const promptMatch = state.tail.match(PROMPT_RE);
const promptText = promptMatch ? promptMatch[0] : '';
if (promptText && promptText !== state.lastPrompt) {
if (state.promptSeen && !state.sawError) {
const now = Date.now();
const ready = now - state.lastToyAt >= runtimeConfig.toyCooldownMs;
if (ready && Math.random() <= runtimeConfig.successToyChance) {
state.lastToyAt = now;
emit(uid, { type: 'command-success' });
}
}
state.promptSeen = true;
state.lastPrompt = promptText;
state.sawError = false;
}
sessionStateByUid.set(uid, state);
}
exports.decorateConfig = (config) => {
runtimeConfig = normalizeConfig(config.hyperPets);
return Object.assign({}, config, {
css: `
${config.css || ''}
.hyperpets-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 12;
}
.hyperpets-floor {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
height: 42px;
opacity: 0.12;
background: linear-gradient(180deg, transparent 0%, rgba(255,255,255,0.15) 100%);
}
.hyperpets-pet {
position: absolute;
bottom: 12px;
width: 48px;
height: 32px;
transform-origin: center bottom;
will-change: transform, left;
}
.hyperpets-toy {
position: absolute;
bottom: 10px;
width: 12px;
height: 12px;
border-radius: 999px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08);
}
.hyperpets-toy.ball {
background: #d85f5f;
}
.hyperpets-toy.bone {
width: 16px;
height: 8px;
border-radius: 999px;
background: #dac7a2;
}
.hyperpets-pet.excited {
filter: saturate(1.25);
}
.hyperpets-pet-svg {
width: 48px;
height: 32px;
overflow: visible;
}
.hyperpets-frame {
width: 48px;
height: 32px;
}
.hyperpets-frame svg {
display: block;
width: 48px;
height: 32px;
}
.hyperpets-z {
position: absolute;
bottom: 28px;
font-size: 10px;
opacity: 0.45;
color: rgba(255,255,255,0.75);
}
`
});
};
exports.middleware = (store) => (next) => (action) => {
if (action && action.type === 'SESSION_ADD_DATA' && action.uid && action.data) {
inspectSessionData(action.uid, action.data);
}
return next(action);
};
exports.decorateTerm = (Term, { React }) => {
class HyperPetsLayer extends React.Component {
constructor(props) {
super(props);
this.state = this.buildInitialState();
this._mounted = false;
this._unsubscribe = null;
this._raf = null;
this._lastFrameAt = 0;
this._container = null;
this._cursorFrame = null;
this._mouseX = null;
this._mouseInfluenceUntil = 0;
this._resizeHandler = this.handleResize.bind(this);
this._mouseHandler = this.handleMouseMove.bind(this);
this._tick = this.tick.bind(this);
}
buildInitialState() {
const config = runtimeConfig;
const pets = [];
for (let index = 0; index < config.petCount; index += 1) {
const now = Date.now();
const type = config.petTypes[index % config.petTypes.length] || 'cat';
pets.push({
id: `pet-${index}`,
type,
x: 24 + index * 48,
vx: 0.1 + Math.random() * 0.14,
facing: 1,
mode: 'idle',
mood: 'calm',
targetX: null,
sleepUntil: 0,
nextDecisionAt: now + 2200 + Math.random() * 1800,
nextAttentionAt: now + 2500 + Math.random() * 2500,
attentionUntil: 0,
zOffset: Math.round(Math.random() * 6),
clip: 'idle',
clipStartedAt: now,
transitionTo: null
});
}
return {
width: 640,
height: 220,
active: this.props.isTermActive !== false,
pets,
toys: []
};
}
componentDidMount() {
this._mounted = true;
this._container = this.props.termRef || null;
this.measure();
window.addEventListener('resize', this._resizeHandler);
if (this._container) {
this._container.addEventListener('mousemove', this._mouseHandler, { passive: true });
}
this._unsubscribe = subscribe(this.props.uid, (event) => this.handlePetEvent(event));
this._raf = window.requestAnimationFrame(this._tick);
}
componentWillUnmount() {
this._mounted = false;
if (this._unsubscribe) {
this._unsubscribe();
}
if (this._raf) {
window.cancelAnimationFrame(this._raf);
}
window.removeEventListener('resize', this._resizeHandler);
if (this._container) {
this._container.removeEventListener('mousemove', this._mouseHandler);
}
}
componentDidUpdate(prevProps) {
if (prevProps.termRef !== this.props.termRef) {
if (this._container) {
this._container.removeEventListener('mousemove', this._mouseHandler);
}
this._container = this.props.termRef || null;
if (this._container) {
this._container.addEventListener('mousemove', this._mouseHandler, { passive: true });
this.measure();
}
}
if (this.props.cursorFrame !== prevProps.cursorFrame) {
this._cursorFrame = this.props.cursorFrame || null;
}
if (this.props.isTermActive !== prevProps.isTermActive) {
this.setState({ active: this.props.isTermActive !== false });
}
}
handleResize() {
this.measure();
}
handleMouseMove(event) {
if (!this._container) {
return;
}
const bounds = this._container.getBoundingClientRect();
this._mouseX = clamp(event.clientX - bounds.left, 0, bounds.width);
this._mouseInfluenceUntil = Date.now() + 2200;
}
handlePetEvent(event) {
if (event.type === 'command-success') {
this.spawnToy();
} else if (event.type === 'error-output') {
this.setState((prevState) => ({
pets: prevState.pets.map((pet) => Object.assign({}, pet, {
mood: 'alert',
mode: pet.mode === 'sleep' ? 'idle' : pet.mode,
sleepUntil: 0,
attentionUntil: 0,
nextAttentionAt: Date.now() + 2200,
nextDecisionAt: Date.now() + 1600
}))
}));
}
}
measure() {
if (!this.props.termRef) {
return;
}
const bounds = this.props.termRef.getBoundingClientRect();
this.setState({
width: Math.max(140, Math.round(bounds.width || 0)),
height: Math.max(80, Math.round(bounds.height || 0))
});
}
spawnToy() {
if (!this._mounted || runtimeConfig.maxToys <= 0) {
return;
}
this.setState((prevState) => {
const width = prevState.width;
const keptToyCount = Math.max(runtimeConfig.maxToys - 1, 0);
const nextToys = keptToyCount === 0 ? [] : prevState.toys.slice(-keptToyCount);
const kind = Math.random() > 0.5 ? 'ball' : 'bone';
const toy = {
id: `toy-${Date.now()}-${Math.round(Math.random() * 9999)}`,
kind,
x: clamp(48 + Math.random() * (width - 96), 12, width - 20),
expiresAt: Date.now() + 12000
};
const leadToy = toy;
const pets = prevState.pets.map((pet, index) => {
if (index > 0) {
return pet;
}
return Object.assign({}, pet, {
mode: 'chase-toy',
mood: 'excited',
targetX: leadToy.x,
sleepUntil: 0,
attentionUntil: Date.now() + 2400,
nextAttentionAt: Date.now() + 5000,
nextDecisionAt: Date.now() + 3200
});
});
return {
toys: nextToys.concat(toy),
pets
};
});
}
tick(timestamp) {
if (!this._mounted) {
return;
}
const dt = this._lastFrameAt ? Math.min(timestamp - this._lastFrameAt, 40) : 16;
this._lastFrameAt = timestamp;
const now = Date.now();
this.setState((prevState) => {
const width = prevState.width;
const active = runtimeConfig.onlyActivePane ? prevState.active : true;
const liveToys = prevState.toys.filter((toy) => toy.expiresAt > now);
const targetToy = liveToys[0] || null;
const cursorTarget = this._cursorFrame ? this._cursorFrame.x : null;
const mouseActive = this._mouseX != null && this._mouseInfluenceUntil > now;
const pets = prevState.pets.map((pet) => {
const next = Object.assign({}, pet);
const laneMax = Math.max(18, width - 44);
const speedScale = runtimeConfig.reducedMotion ? 0.45 : 1;
const travel = next.vx * runtimeConfig.walkSpeed * dt * speedScale;
const attentionActive = next.attentionUntil > now;
if (!active) {
next.mode = 'sleep';
next.sleepUntil = now + 5000;
next.mood = 'calm';
next.targetX = null;
next.attentionUntil = 0;
} else if (targetToy) {
next.targetX = targetToy.x;
next.mode = 'chase-toy';
next.mood = 'excited';
} else if (!attentionActive && now >= next.nextAttentionAt) {
const shouldNoticeMouse = mouseActive && Math.random() < runtimeConfig.chaseCursorChance;
const shouldNoticeCursor = cursorTarget != null && Math.random() < runtimeConfig.chaseCursorChance * 0.65;
if (shouldNoticeMouse) {
next.targetX = this._mouseX;
next.mode = 'chase-cursor';
next.mood = 'curious';
next.attentionUntil = now + 1200 + Math.random() * 1200;
next.nextAttentionAt = now + 5000 + Math.random() * 5000;
next.nextDecisionAt = now + 1800 + Math.random() * 1200;
} else if (shouldNoticeCursor) {
next.targetX = cursorTarget;
next.mode = 'inspect';
next.mood = 'curious';
next.attentionUntil = now + 900 + Math.random() * 900;
next.nextAttentionAt = now + 4200 + Math.random() * 4200;
next.nextDecisionAt = now + 1500 + Math.random() * 900;
} else {
next.nextAttentionAt = now + 1800 + Math.random() * 2800;
}
} else if (now >= next.nextDecisionAt) {
const roll = Math.random();
if (roll < 0.2) {
next.mode = 'sleep';
next.sleepUntil = now + 3500 + Math.random() * 5000;
next.targetX = null;
next.attentionUntil = 0;
} else if (roll < 0.78) {
next.mode = 'walk';
next.targetX = clamp(24 + Math.random() * (width - 48), 16, laneMax);
next.attentionUntil = now + 1200 + Math.random() * 1600;
} else {
next.mode = 'idle';
next.targetX = null;
next.attentionUntil = 0;
}
next.mood = 'calm';
next.nextDecisionAt = now + 3200 + Math.random() * 3400;
}
if (next.mode === 'sleep' && now < next.sleepUntil) {
return next;
}
if (next.mode === 'sleep' && now >= next.sleepUntil) {
next.mode = 'idle';
next.nextDecisionAt = now + 1600 + Math.random() * 1600;
next.nextAttentionAt = now + 2000 + Math.random() * 2000;
}
if (typeof next.targetX === 'number') {
const dx = next.targetX - next.x;
if (Math.abs(dx) < 4) {
next.targetX = null;
next.attentionUntil = 0;
next.mode = 'idle';
} else {
next.facing = dx >= 0 ? 1 : -1;
next.x = clamp(next.x + Math.sign(dx) * travel, 12, laneMax);
if (next.mode !== 'chase-toy' && next.mode !== 'inspect' && next.mode !== 'chase-cursor') {
next.mode = 'walk';
}
}
} else if (next.mode === 'walk') {
next.mode = 'idle';
}
if (next.x <= 12 || next.x >= laneMax) {
next.facing *= -1;
}
const desiredClip = modeToClip(next.mode);
const animationState = resolveAnimationState(next, desiredClip, now, runtimeConfig.reducedMotion);
next.clip = animationState.clip;
next.clipStartedAt = animationState.clipStartedAt;
next.transitionTo = animationState.transitionTo;
return next;
});
return {
toys: liveToys,
pets
};
});
this._raf = window.requestAnimationFrame(this._tick);
}
renderToy(toy) {
return React.createElement('div', {
key: toy.id,
className: `hyperpets-toy ${toy.kind}`,
style: {
left: `${toy.x}px`
}
});
}
renderPet(pet) {
const frame = getAnimatedFrame(pet, runtimeConfig.reducedMotion, Date.now());
const className = [
'hyperpets-pet',
pet.mood === 'excited' ? 'excited' : ''
].filter(Boolean).join(' ');
return React.createElement(
'div',
{
key: pet.id,
className,
style: {
left: `${pet.x}px`,
transform: `scaleX(${pet.facing}) translateZ(0)`
}
},
React.createElement(
'div',
{
className: 'hyperpets-frame',
dangerouslySetInnerHTML: { __html: frame.svg }
}
),
pet.mode === 'sleep'
? React.createElement('div', {
className: 'hyperpets-z',
style: { left: '30px', bottom: `${26 + pet.zOffset}px` }
}, 'z')
: null
);
}
render() {
if (!runtimeConfig.enabled) {
return null;
}
return React.createElement(
'div',
{ className: 'hyperpets-layer' },
React.createElement('div', { className: 'hyperpets-floor' }),
this.state.toys.map((toy) => this.renderToy(toy)),
this.state.pets.map((pet) => this.renderPet(pet))
);
}
}
return class HyperPetsTerm extends React.Component {
constructor(props) {
super(props);
this.state = {
termRef: null,
cursorFrame: null
};
this._lastCursorSyncAt = 0;
this._lastCursorX = null;
this.handleDecorated = this.handleDecorated.bind(this);
this.handleCursorMove = this.handleCursorMove.bind(this);
}
handleDecorated(term) {
if (this.props.onDecorated) {
this.props.onDecorated(term);
}
this.setState({
termRef: term ? term.termRef : null
});
}
handleCursorMove(cursorFrame) {
if (this.props.onCursorMove) {
this.props.onCursorMove(cursorFrame);
}
const now = Date.now();
const x = cursorFrame && typeof cursorFrame.x === 'number' ? cursorFrame.x : null;
const movedEnough = x == null || this._lastCursorX == null || Math.abs(x - this._lastCursorX) >= 10;
const staleEnough = now - this._lastCursorSyncAt >= 180;
if (movedEnough && staleEnough) {
this._lastCursorSyncAt = now;
this._lastCursorX = x;
this.setState({ cursorFrame });
}
}
render() {
const customChildren = []
.concat(this.props.customChildren || [])
.concat(React.createElement(HyperPetsLayer, {
key: 'hyperpets-layer',
uid: this.props.uid,
termRef: this.state.termRef,
cursorFrame: this.state.cursorFrame,
isTermActive: this.props.isTermActive
}));
return React.createElement(Term, Object.assign({}, this.props, {
onDecorated: this.handleDecorated,
onCursorMove: this.handleCursorMove,
customChildren
}));
}
};
};

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "hyperpets",
"version": "0.1.0",
"description": "Animated pet companions for Hyper terminal panes.",
"main": "index.js",
"license": "MIT",
"keywords": [
"hyper",
"hyper-plugin",
"terminal",
"pets"
]
}