honeytree 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/package.json +5 -5
- package/src/plant.js +12 -5
- package/src/renderer.js +58 -11
- package/src/viewer.js +81 -17
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Honeytree
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/honeytree)
|
|
4
|
+
[](https://github.com/Varun2009178/honeytree/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
Grow a pixel-art forest in your terminal every time you use Claude Code.
|
|
7
|
+
|
|
8
|
+
Each prompt plants a new tree. Each tree grows over time. Your forest evolves from a quiet clearing into an ancient woodland — and it never resets.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g honeytree
|
|
16
|
+
honeytree init
|
|
17
|
+
honeytree
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it. Three commands:
|
|
21
|
+
|
|
22
|
+
1. **Install** the CLI globally
|
|
23
|
+
2. **Init** creates your forest file and registers a Claude Code hook
|
|
24
|
+
3. **Run the viewer** in a separate terminal to watch your forest grow
|
|
25
|
+
|
|
26
|
+
After setup, trees are planted automatically after every Claude Code response. No manual steps needed.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## How It Works
|
|
31
|
+
|
|
32
|
+
When you run `honeytree init`, it does two things:
|
|
33
|
+
|
|
34
|
+
- Creates `~/.honeydew/forest.json` to store your forest state
|
|
35
|
+
- Adds a `Stop` hook to `~/.claude/settings.json` that runs after every Claude Code response
|
|
36
|
+
|
|
37
|
+
From then on, every time Claude Code responds to a prompt, a new tree is planted in your forest automatically. Open the viewer in a second terminal to watch them grow in real time.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Biomes
|
|
42
|
+
|
|
43
|
+
Your forest evolves visually as it grows — the sky, ground, and atmosphere all change:
|
|
44
|
+
|
|
45
|
+
| Trees | Biome | What changes |
|
|
46
|
+
|------:|-------|-------------|
|
|
47
|
+
| 0–9 | Clearing | Sparse stars, light ground |
|
|
48
|
+
| 10–24 | Grove | More stars, richer ground |
|
|
49
|
+
| 25–49 | Woodland | Dense canopy, varied starlight |
|
|
50
|
+
| 50–99 | Old Growth | Deep greens, warm starlight |
|
|
51
|
+
| 100+ | Ancient Forest | Richest palette, brightest sky |
|
|
52
|
+
|
|
53
|
+
Trees are never deleted. The forest only grows.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Tree Species
|
|
58
|
+
|
|
59
|
+
Five species are randomly assigned when a tree is planted:
|
|
60
|
+
|
|
61
|
+
| Species | Look |
|
|
62
|
+
|---------|------|
|
|
63
|
+
| Oak | Wide, rounded canopy |
|
|
64
|
+
| Pine | Tall, triangular shape |
|
|
65
|
+
| Birch | Light trunk, bright leaves |
|
|
66
|
+
| Willow | Drooping canopy |
|
|
67
|
+
| Cherry | Pink blossoms |
|
|
68
|
+
|
|
69
|
+
Each species has 4 growth stages (seed, sapling, young, full). Existing trees grow a little with each new prompt.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Viewer
|
|
74
|
+
|
|
75
|
+
The viewer adapts to your terminal width — expand your terminal and new trees will spread across the full width.
|
|
76
|
+
|
|
77
|
+
Press `Ctrl+C` to exit. The viewer shows a summary of your forest when you close it.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Node.js 18+
|
|
84
|
+
- [Claude Code](https://claude.com/claude-code) (for the automatic hook)
|
|
85
|
+
|
|
86
|
+
## Links
|
|
87
|
+
|
|
88
|
+
- **npm**: [npmjs.com/package/honeytree](https://www.npmjs.com/package/honeytree)
|
|
89
|
+
- **GitHub**: [github.com/Varun2009178/honeytree](https://github.com/Varun2009178/honeytree)
|
|
90
|
+
- **Issues**: [github.com/Varun2009178/honeytree/issues](https://github.com/Varun2009178/honeytree/issues)
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "honeytree",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Grow a
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Grow a forest in your terminal every time you use Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"honeytree": "./bin/honeydew.js"
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
],
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/
|
|
27
|
+
"url": "git+https://github.com/Varun2009178/honeytree.git"
|
|
28
28
|
},
|
|
29
|
-
"homepage": "https://github.com/
|
|
29
|
+
"homepage": "https://github.com/Varun2009178/honeytree#readme",
|
|
30
30
|
"bugs": {
|
|
31
|
-
"url": "https://github.com/
|
|
31
|
+
"url": "https://github.com/Varun2009178/honeytree/issues"
|
|
32
32
|
},
|
|
33
33
|
"author": "Varun Nukala",
|
|
34
34
|
"license": "MIT",
|
package/src/plant.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { getSprite, TREE_TYPES } from "./sprites.js";
|
|
2
2
|
import { createEmptyForest, readForest, writeForest } from "./state.js";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_WIDTH = 80;
|
|
5
4
|
const MIN_GAP = 2;
|
|
5
|
+
const DEFAULT_WIDTH = 80;
|
|
6
|
+
|
|
7
|
+
function getPlantWidth(forest) {
|
|
8
|
+
// Use the width saved by the viewer, fall back to default
|
|
9
|
+
if (forest.viewerWidth && forest.viewerWidth > 40) return forest.viewerWidth;
|
|
10
|
+
return DEFAULT_WIDTH;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
function randomItem(items) {
|
|
8
14
|
return items[Math.floor(Math.random() * items.length)];
|
|
@@ -20,7 +26,7 @@ function occupiedRanges(trees) {
|
|
|
20
26
|
});
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
function findOpenX(trees, type, growth) {
|
|
29
|
+
function findOpenX(trees, type, growth, width) {
|
|
24
30
|
const sprite = getSprite(type, growth);
|
|
25
31
|
const half = Math.floor(sprite.width / 2);
|
|
26
32
|
const margin = half + 1;
|
|
@@ -28,7 +34,7 @@ function findOpenX(trees, type, growth) {
|
|
|
28
34
|
|
|
29
35
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
30
36
|
const x =
|
|
31
|
-
margin + Math.floor(Math.random() * Math.max(1,
|
|
37
|
+
margin + Math.floor(Math.random() * Math.max(1, width - margin * 2));
|
|
32
38
|
const left = x - half;
|
|
33
39
|
const right = x + half;
|
|
34
40
|
const collides = ranges.some(
|
|
@@ -38,7 +44,7 @@ function findOpenX(trees, type, growth) {
|
|
|
38
44
|
if (!collides) return x;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
return margin + Math.floor(Math.random() * Math.max(1,
|
|
47
|
+
return margin + Math.floor(Math.random() * Math.max(1, width - margin * 2));
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
function nudgeGrowth(growth) {
|
|
@@ -49,6 +55,7 @@ function nudgeGrowth(growth) {
|
|
|
49
55
|
|
|
50
56
|
export async function plant() {
|
|
51
57
|
const forest = readForest() ?? createEmptyForest();
|
|
58
|
+
const width = getPlantWidth(forest);
|
|
52
59
|
|
|
53
60
|
for (const tree of forest.trees) {
|
|
54
61
|
tree.growth = nudgeGrowth(tree.growth);
|
|
@@ -62,7 +69,7 @@ export async function plant() {
|
|
|
62
69
|
id: nextId,
|
|
63
70
|
type,
|
|
64
71
|
growth,
|
|
65
|
-
x: findOpenX(forest.trees, type, growth),
|
|
72
|
+
x: findOpenX(forest.trees, type, growth, width),
|
|
66
73
|
plantedAt: new Date().toISOString(),
|
|
67
74
|
});
|
|
68
75
|
forest.totalPrompts += 1;
|
package/src/renderer.js
CHANGED
|
@@ -11,14 +11,59 @@ const STATS_ROWS = 1;
|
|
|
11
11
|
export const SCENE_HEIGHT =
|
|
12
12
|
SKY_ROWS + TREE_ROWS + GROUND_ROWS + SPACER_ROWS + STATS_ROWS;
|
|
13
13
|
|
|
14
|
-
const STAR_GLYPHS = ["·", "·", "✦", "."];
|
|
15
|
-
const GROUND_COLORS = ["#22492d", "#18361f"];
|
|
16
14
|
const STATS_ACCENT = "#f5a50b";
|
|
17
15
|
const STATS_TEXT = "#8e8a84";
|
|
18
16
|
const BAR_FILL = "#6cb95e";
|
|
19
17
|
const BAR_EMPTY = "#3d3d3d";
|
|
20
18
|
const MILESTONES = [10, 25, 50, 100, 250, 500, 1000];
|
|
21
19
|
|
|
20
|
+
// Biomes evolve as the forest grows — never resets, only gets richer
|
|
21
|
+
const BIOMES = [
|
|
22
|
+
{ // 0-9: sparse clearing
|
|
23
|
+
ground: ["#2a3a28", "#1e2d1c"],
|
|
24
|
+
starGlyphs: ["·", ".", " ", " "],
|
|
25
|
+
starDensity: 12,
|
|
26
|
+
starColors: ["#3a3a3a", "#444444"],
|
|
27
|
+
label: "clearing",
|
|
28
|
+
},
|
|
29
|
+
{ // 10-24: young grove
|
|
30
|
+
ground: ["#22492d", "#18361f"],
|
|
31
|
+
starGlyphs: ["·", "·", "✦", "."],
|
|
32
|
+
starDensity: 9,
|
|
33
|
+
starColors: ["#444444", "#5d5d5d"],
|
|
34
|
+
label: "grove",
|
|
35
|
+
},
|
|
36
|
+
{ // 25-49: dense woodland
|
|
37
|
+
ground: ["#1e4a28", "#163a1e"],
|
|
38
|
+
starGlyphs: ["·", "✦", "✧", "·", "."],
|
|
39
|
+
starDensity: 7,
|
|
40
|
+
starColors: ["#4d4d4d", "#5d5d5d", "#6a6a55"],
|
|
41
|
+
label: "woodland",
|
|
42
|
+
},
|
|
43
|
+
{ // 50-99: old growth
|
|
44
|
+
ground: ["#1a5230", "#124020"],
|
|
45
|
+
starGlyphs: ["✦", "✧", "·", "·", "✦", "."],
|
|
46
|
+
starDensity: 6,
|
|
47
|
+
starColors: ["#5d5d5d", "#6d6d5a", "#7a7a60"],
|
|
48
|
+
label: "old growth",
|
|
49
|
+
},
|
|
50
|
+
{ // 100+: ancient forest
|
|
51
|
+
ground: ["#165a32", "#0e4822"],
|
|
52
|
+
starGlyphs: ["✦", "✧", "·", "✦", "⋆", "."],
|
|
53
|
+
starDensity: 5,
|
|
54
|
+
starColors: ["#6d6d5a", "#7a7a60", "#8a8a6a"],
|
|
55
|
+
label: "ancient forest",
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function getBiome(treeCount) {
|
|
60
|
+
if (treeCount < 10) return BIOMES[0];
|
|
61
|
+
if (treeCount < 25) return BIOMES[1];
|
|
62
|
+
if (treeCount < 50) return BIOMES[2];
|
|
63
|
+
if (treeCount < 100) return BIOMES[3];
|
|
64
|
+
return BIOMES[4];
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
function createBuffer(width) {
|
|
23
68
|
return Array.from({ length: SCENE_HEIGHT }, () =>
|
|
24
69
|
Array.from({ length: width }, () => ({ char: " ", color: null })),
|
|
@@ -32,16 +77,16 @@ function hash(seed) {
|
|
|
32
77
|
return ((value >>> 16) ^ value) >>> 0;
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
function generateStars(width, twinkle = 0) {
|
|
80
|
+
function generateStars(width, biome, twinkle = 0) {
|
|
36
81
|
const stars = [];
|
|
37
82
|
for (let x = 0; x < width; x += 1) {
|
|
38
83
|
const seeded = hash(x + width * 17 + twinkle * 101);
|
|
39
|
-
if (seeded %
|
|
84
|
+
if (seeded % biome.starDensity !== 0) continue;
|
|
40
85
|
stars.push({
|
|
41
86
|
x,
|
|
42
87
|
y: seeded % SKY_ROWS,
|
|
43
|
-
char:
|
|
44
|
-
color: seeded %
|
|
88
|
+
char: biome.starGlyphs[seeded % biome.starGlyphs.length],
|
|
89
|
+
color: biome.starColors[seeded % biome.starColors.length],
|
|
45
90
|
});
|
|
46
91
|
}
|
|
47
92
|
return stars;
|
|
@@ -78,7 +123,7 @@ function getDayCount(createdAt) {
|
|
|
78
123
|
return Math.max(1, days + 1);
|
|
79
124
|
}
|
|
80
125
|
|
|
81
|
-
function buildStatsLine(forest) {
|
|
126
|
+
function buildStatsLine(forest, biome) {
|
|
82
127
|
const treeCount = forest.trees.length;
|
|
83
128
|
const milestone = getNextMilestone(treeCount);
|
|
84
129
|
const progress = milestone === 0 ? 0 : treeCount / milestone;
|
|
@@ -96,7 +141,8 @@ function buildStatsLine(forest) {
|
|
|
96
141
|
)} day${getDayCount(forest.createdAt) === 1 ? "" : "s"} · `,
|
|
97
142
|
) +
|
|
98
143
|
bar +
|
|
99
|
-
chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`)
|
|
144
|
+
chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
|
|
145
|
+
chalk.hex("#555555")(` [${biome.label}]`)
|
|
100
146
|
);
|
|
101
147
|
}
|
|
102
148
|
|
|
@@ -104,8 +150,9 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
104
150
|
const width = Math.max(40, termWidth);
|
|
105
151
|
const buffer = createBuffer(width);
|
|
106
152
|
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
153
|
+
const biome = getBiome(forest.trees.length);
|
|
107
154
|
|
|
108
|
-
for (const star of generateStars(width, options.twinkleSeed ?? 0)) {
|
|
155
|
+
for (const star of generateStars(width, biome, options.twinkleSeed ?? 0)) {
|
|
109
156
|
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
110
157
|
}
|
|
111
158
|
|
|
@@ -113,7 +160,7 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
113
160
|
for (let x = 0; x < width; x += 1) {
|
|
114
161
|
buffer[groundStart + rowIndex][x] = {
|
|
115
162
|
char: "█",
|
|
116
|
-
color:
|
|
163
|
+
color: biome.ground[rowIndex],
|
|
117
164
|
};
|
|
118
165
|
}
|
|
119
166
|
}
|
|
@@ -133,7 +180,7 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
lines.push("");
|
|
136
|
-
lines.push(buildStatsLine(forest));
|
|
183
|
+
lines.push(buildStatsLine(forest, biome));
|
|
137
184
|
|
|
138
185
|
return lines.join("\n");
|
|
139
186
|
}
|
package/src/viewer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
|
|
3
3
|
import { renderFrame } from "./renderer.js";
|
|
4
|
-
import { getForestFile, readForest } from "./state.js";
|
|
4
|
+
import { getForestFile, readForest, writeForest } from "./state.js";
|
|
5
5
|
|
|
6
6
|
function writeAnsi(code) {
|
|
7
7
|
process.stdout.write(code);
|
|
@@ -63,11 +63,25 @@ export async function viewer() {
|
|
|
63
63
|
process.exit(1);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Save terminal width so plant knows how wide to spread trees
|
|
67
|
+
let ignoreNextChange = false;
|
|
68
|
+
function syncWidth() {
|
|
69
|
+
const cols = process.stdout.columns || 80;
|
|
70
|
+
if (forest.viewerWidth !== cols) {
|
|
71
|
+
forest.viewerWidth = cols;
|
|
72
|
+
ignoreNextChange = true;
|
|
73
|
+
writeForest(forest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
syncWidth();
|
|
66
78
|
hideCursor();
|
|
67
79
|
clearScreen();
|
|
68
80
|
renderForest(forest);
|
|
69
81
|
|
|
70
82
|
let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
|
|
83
|
+
let lastTotalPrompts = forest.totalPrompts;
|
|
84
|
+
let animating = false;
|
|
71
85
|
|
|
72
86
|
const cleanup = () => {
|
|
73
87
|
showCursor();
|
|
@@ -81,25 +95,75 @@ export async function viewer() {
|
|
|
81
95
|
process.on("SIGINT", cleanup);
|
|
82
96
|
process.on("SIGTERM", cleanup);
|
|
83
97
|
process.stdout.on("resize", () => {
|
|
98
|
+
syncWidth();
|
|
84
99
|
clearScreen();
|
|
85
100
|
renderForest(forest);
|
|
86
101
|
});
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
// Check for changes — used by both fs.watch and polling fallback
|
|
104
|
+
async function checkForUpdates() {
|
|
105
|
+
if (animating) return;
|
|
106
|
+
|
|
107
|
+
if (ignoreNextChange) {
|
|
108
|
+
ignoreNextChange = false;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const updated = readForest();
|
|
113
|
+
if (!updated) return;
|
|
114
|
+
|
|
115
|
+
// Only re-render if something actually changed
|
|
116
|
+
if (updated.totalPrompts === lastTotalPrompts) return;
|
|
117
|
+
|
|
118
|
+
const nextMaxId = updated.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
|
|
119
|
+
forest = updated;
|
|
120
|
+
lastTotalPrompts = forest.totalPrompts;
|
|
121
|
+
|
|
122
|
+
if (nextMaxId > lastMaxId) {
|
|
123
|
+
lastMaxId = nextMaxId;
|
|
124
|
+
animating = true;
|
|
125
|
+
await animateNewTree(forest, nextMaxId);
|
|
126
|
+
animating = false;
|
|
127
|
+
} else {
|
|
128
|
+
renderForest(forest);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// fs.watch can drop events on macOS after atomic renames, so
|
|
133
|
+
// use it for fast response but also poll as a reliable fallback
|
|
134
|
+
function startWatcher() {
|
|
135
|
+
try {
|
|
136
|
+
const watcher = fs.watch(forestFile, () => {
|
|
137
|
+
checkForUpdates();
|
|
138
|
+
});
|
|
139
|
+
watcher.on("error", () => {});
|
|
140
|
+
return watcher;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let watcher = startWatcher();
|
|
147
|
+
|
|
148
|
+
// Poll every 800ms as fallback — cheap since it only reads if mtime changed
|
|
149
|
+
let lastMtime = 0;
|
|
150
|
+
try {
|
|
151
|
+
lastMtime = fs.statSync(forestFile).mtimeMs;
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
154
|
+
setInterval(() => {
|
|
155
|
+
try {
|
|
156
|
+
const mtime = fs.statSync(forestFile).mtimeMs;
|
|
157
|
+
if (mtime !== lastMtime) {
|
|
158
|
+
lastMtime = mtime;
|
|
159
|
+
checkForUpdates();
|
|
160
|
+
|
|
161
|
+
// Re-establish watcher in case rename killed it
|
|
162
|
+
if (watcher) {
|
|
163
|
+
try { watcher.close(); } catch {}
|
|
164
|
+
}
|
|
165
|
+
watcher = startWatcher();
|
|
102
166
|
}
|
|
103
|
-
}
|
|
104
|
-
});
|
|
167
|
+
} catch {}
|
|
168
|
+
}, 800);
|
|
105
169
|
}
|