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 ADDED
@@ -0,0 +1,94 @@
1
+ # Honeytree
2
+
3
+ [![npm version](https://img.shields.io/npm/v/honeytree.svg)](https://www.npmjs.com/package/honeytree)
4
+ [![license](https://img.shields.io/npm/l/honeytree.svg)](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.0",
4
- "description": "Grow a pixel-art forest in your terminal every time you use Claude Code",
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/varunnukala/honeytree.git"
27
+ "url": "git+https://github.com/Varun2009178/honeytree.git"
28
28
  },
29
- "homepage": "https://github.com/varunnukala/honeytree#readme",
29
+ "homepage": "https://github.com/Varun2009178/honeytree#readme",
30
30
  "bugs": {
31
- "url": "https://github.com/varunnukala/honeytree/issues"
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, DEFAULT_WIDTH - margin * 2));
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, DEFAULT_WIDTH - margin * 2));
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 % 9 !== 0) continue;
84
+ if (seeded % biome.starDensity !== 0) continue;
40
85
  stars.push({
41
86
  x,
42
87
  y: seeded % SKY_ROWS,
43
- char: STAR_GLYPHS[seeded % STAR_GLYPHS.length],
44
- color: seeded % 3 === 0 ? "#5d5d5d" : "#444444",
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: GROUND_COLORS[rowIndex],
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
- let debounceTimer;
89
- fs.watch(forestFile, () => {
90
- clearTimeout(debounceTimer);
91
- debounceTimer = setTimeout(async () => {
92
- const updated = readForest();
93
- if (!updated) return;
94
-
95
- const nextMaxId = updated.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
96
- forest = updated;
97
- if (nextMaxId > lastMaxId) {
98
- lastMaxId = nextMaxId;
99
- await animateNewTree(forest, nextMaxId);
100
- } else {
101
- renderForest(forest);
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
- }, 100);
104
- });
167
+ } catch {}
168
+ }, 800);
105
169
  }