honeytree 0.1.0

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.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ const command = process.argv[2];
4
+
5
+ if (command === "init") {
6
+ const { init } = await import("../src/init.js");
7
+ await init();
8
+ } else if (command === "plant") {
9
+ const { plant } = await import("../src/plant.js");
10
+ await plant();
11
+ } else if (!command) {
12
+ const { viewer } = await import("../src/viewer.js");
13
+ await viewer();
14
+ } else {
15
+ console.error(`Unknown command: ${command}`);
16
+ console.error("Usage: honeytree [init|plant]");
17
+ process.exit(1);
18
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "honeytree",
3
+ "version": "0.1.0",
4
+ "description": "Grow a pixel-art forest in your terminal every time you use Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "honeytree": "./bin/honeydew.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "cli",
18
+ "terminal",
19
+ "forest",
20
+ "claude-code",
21
+ "pixel-art",
22
+ "ascii-art",
23
+ "hooks"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/varunnukala/honeytree.git"
28
+ },
29
+ "homepage": "https://github.com/varunnukala/honeytree#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/varunnukala/honeytree/issues"
32
+ },
33
+ "author": "Varun Nukala",
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "chalk": "^5.4.1"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
package/src/init.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ createEmptyForest,
7
+ getHoneydewDir,
8
+ readForest,
9
+ writeForest,
10
+ } from "./state.js";
11
+
12
+ function getClaudeSettingsPath() {
13
+ const claudeRoot =
14
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
15
+ return path.join(claudeRoot, "settings.json");
16
+ }
17
+
18
+ const HONEYDEW_STOP_HOOK = {
19
+ matcher: "",
20
+ hooks: [
21
+ {
22
+ type: "command",
23
+ command: "honeytree plant",
24
+ },
25
+ ],
26
+ };
27
+
28
+ function hasHoneydewHook(settings) {
29
+ return (
30
+ settings?.hooks?.Stop?.some((entry) =>
31
+ entry?.hooks?.some((hook) => hook?.command === "honeytree plant"),
32
+ ) ?? false
33
+ );
34
+ }
35
+
36
+ export async function init() {
37
+ const honeydewDir = getHoneydewDir();
38
+ fs.mkdirSync(honeydewDir, { recursive: true });
39
+
40
+ if (!readForest()) {
41
+ writeForest(createEmptyForest());
42
+ console.log(`Created ${path.join(honeydewDir, "forest.json")}`);
43
+ } else {
44
+ console.log(`Forest already exists at ${path.join(honeydewDir, "forest.json")}`);
45
+ }
46
+
47
+ const settingsPath = getClaudeSettingsPath();
48
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
49
+
50
+ let settings = {};
51
+ try {
52
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
53
+ } catch {
54
+ settings = {};
55
+ }
56
+
57
+ settings.hooks ??= {};
58
+ settings.hooks.Stop ??= [];
59
+
60
+ if (hasHoneydewHook(settings)) {
61
+ console.log(`Claude Code hook already configured in ${settingsPath}`);
62
+ } else {
63
+ settings.hooks.Stop.push(HONEYDEW_STOP_HOOK);
64
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
65
+ console.log(`Added honeytree Stop hook to ${settingsPath}`);
66
+ }
67
+
68
+ console.log("");
69
+ console.log("Setup complete.");
70
+ console.log("Run `honeytree` in a separate terminal to watch the forest grow.");
71
+ }
package/src/plant.js ADDED
@@ -0,0 +1,71 @@
1
+ import { getSprite, TREE_TYPES } from "./sprites.js";
2
+ import { createEmptyForest, readForest, writeForest } from "./state.js";
3
+
4
+ const DEFAULT_WIDTH = 80;
5
+ const MIN_GAP = 2;
6
+
7
+ function randomItem(items) {
8
+ return items[Math.floor(Math.random() * items.length)];
9
+ }
10
+
11
+ function randomGrowth() {
12
+ return Math.round((0.3 + Math.random() * 0.7) * 100) / 100;
13
+ }
14
+
15
+ function occupiedRanges(trees) {
16
+ return trees.map((tree) => {
17
+ const sprite = getSprite(tree.type, tree.growth);
18
+ const half = Math.floor(sprite.width / 2);
19
+ return [tree.x - half - MIN_GAP, tree.x + half + MIN_GAP];
20
+ });
21
+ }
22
+
23
+ function findOpenX(trees, type, growth) {
24
+ const sprite = getSprite(type, growth);
25
+ const half = Math.floor(sprite.width / 2);
26
+ const margin = half + 1;
27
+ const ranges = occupiedRanges(trees);
28
+
29
+ for (let attempt = 0; attempt < 100; attempt += 1) {
30
+ const x =
31
+ margin + Math.floor(Math.random() * Math.max(1, DEFAULT_WIDTH - margin * 2));
32
+ const left = x - half;
33
+ const right = x + half;
34
+ const collides = ranges.some(
35
+ ([occupiedLeft, occupiedRight]) =>
36
+ left < occupiedRight && right > occupiedLeft,
37
+ );
38
+ if (!collides) return x;
39
+ }
40
+
41
+ return margin + Math.floor(Math.random() * Math.max(1, DEFAULT_WIDTH - margin * 2));
42
+ }
43
+
44
+ function nudgeGrowth(growth) {
45
+ if (growth >= 1) return 1;
46
+ const nextGrowth = growth + 0.1 + Math.random() * 0.1;
47
+ return Math.min(1, Math.round(nextGrowth * 100) / 100);
48
+ }
49
+
50
+ export async function plant() {
51
+ const forest = readForest() ?? createEmptyForest();
52
+
53
+ for (const tree of forest.trees) {
54
+ tree.growth = nudgeGrowth(tree.growth);
55
+ }
56
+
57
+ const type = randomItem(TREE_TYPES);
58
+ const growth = randomGrowth();
59
+ const nextId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0) + 1;
60
+
61
+ forest.trees.push({
62
+ id: nextId,
63
+ type,
64
+ growth,
65
+ x: findOpenX(forest.trees, type, growth),
66
+ plantedAt: new Date().toISOString(),
67
+ });
68
+ forest.totalPrompts += 1;
69
+
70
+ writeForest(forest);
71
+ }
@@ -0,0 +1,139 @@
1
+ import chalk from "chalk";
2
+
3
+ import { getSprite, TREE_TYPES } from "./sprites.js";
4
+
5
+ const SKY_ROWS = 4;
6
+ const TREE_ROWS = 7;
7
+ const GROUND_ROWS = 2;
8
+ const SPACER_ROWS = 1;
9
+ const STATS_ROWS = 1;
10
+
11
+ export const SCENE_HEIGHT =
12
+ SKY_ROWS + TREE_ROWS + GROUND_ROWS + SPACER_ROWS + STATS_ROWS;
13
+
14
+ const STAR_GLYPHS = ["·", "·", "✦", "."];
15
+ const GROUND_COLORS = ["#22492d", "#18361f"];
16
+ const STATS_ACCENT = "#f5a50b";
17
+ const STATS_TEXT = "#8e8a84";
18
+ const BAR_FILL = "#6cb95e";
19
+ const BAR_EMPTY = "#3d3d3d";
20
+ const MILESTONES = [10, 25, 50, 100, 250, 500, 1000];
21
+
22
+ function createBuffer(width) {
23
+ return Array.from({ length: SCENE_HEIGHT }, () =>
24
+ Array.from({ length: width }, () => ({ char: " ", color: null })),
25
+ );
26
+ }
27
+
28
+ function hash(seed) {
29
+ let value = seed >>> 0;
30
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
31
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
32
+ return ((value >>> 16) ^ value) >>> 0;
33
+ }
34
+
35
+ function generateStars(width, twinkle = 0) {
36
+ const stars = [];
37
+ for (let x = 0; x < width; x += 1) {
38
+ const seeded = hash(x + width * 17 + twinkle * 101);
39
+ if (seeded % 9 !== 0) continue;
40
+ stars.push({
41
+ x,
42
+ y: seeded % SKY_ROWS,
43
+ char: STAR_GLYPHS[seeded % STAR_GLYPHS.length],
44
+ color: seeded % 3 === 0 ? "#5d5d5d" : "#444444",
45
+ });
46
+ }
47
+ return stars;
48
+ }
49
+
50
+ function compositeSprite(buffer, sprite, centerX, baseY) {
51
+ const offsetX = centerX - Math.floor(sprite.width / 2);
52
+ for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
53
+ const targetY = baseY - rowIndex;
54
+ if (targetY < 0 || targetY >= buffer.length) continue;
55
+ const row = sprite.rows[rowIndex];
56
+ for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
57
+ const targetX = offsetX + columnIndex;
58
+ if (targetX < 0 || targetX >= buffer[0].length) continue;
59
+ const [char, color] = row[columnIndex];
60
+ if (!color) continue;
61
+ buffer[targetY][targetX] = { char, color };
62
+ }
63
+ }
64
+ }
65
+
66
+ function getNextMilestone(treeCount) {
67
+ return MILESTONES.find((value) => treeCount < value) ?? treeCount + 100;
68
+ }
69
+
70
+ function getNextTreeType(treeCount) {
71
+ return TREE_TYPES[treeCount % TREE_TYPES.length];
72
+ }
73
+
74
+ function getDayCount(createdAt) {
75
+ const created = new Date(createdAt).getTime();
76
+ const diff = Date.now() - created;
77
+ const days = Math.floor(diff / (24 * 60 * 60 * 1000));
78
+ return Math.max(1, days + 1);
79
+ }
80
+
81
+ function buildStatsLine(forest) {
82
+ const treeCount = forest.trees.length;
83
+ const milestone = getNextMilestone(treeCount);
84
+ const progress = milestone === 0 ? 0 : treeCount / milestone;
85
+ const barWidth = 12;
86
+ const filledWidth = Math.max(0, Math.min(barWidth, Math.round(progress * barWidth)));
87
+ const bar =
88
+ chalk.hex(BAR_FILL)("█".repeat(filledWidth)) +
89
+ chalk.hex(BAR_EMPTY)("░".repeat(barWidth - filledWidth));
90
+
91
+ return (
92
+ chalk.hex(STATS_ACCENT)(" honeytree") +
93
+ chalk.hex(STATS_TEXT)(
94
+ ` · ${treeCount} tree${treeCount === 1 ? "" : "s"} · ${getDayCount(
95
+ forest.createdAt,
96
+ )} day${getDayCount(forest.createdAt) === 1 ? "" : "s"} · `,
97
+ ) +
98
+ bar +
99
+ chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`)
100
+ );
101
+ }
102
+
103
+ export function renderFrame(forest, termWidth = 80, options = {}) {
104
+ const width = Math.max(40, termWidth);
105
+ const buffer = createBuffer(width);
106
+ const groundStart = SKY_ROWS + TREE_ROWS;
107
+
108
+ for (const star of generateStars(width, options.twinkleSeed ?? 0)) {
109
+ buffer[star.y][star.x] = { char: star.char, color: star.color };
110
+ }
111
+
112
+ for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
113
+ for (let x = 0; x < width; x += 1) {
114
+ buffer[groundStart + rowIndex][x] = {
115
+ char: "█",
116
+ color: GROUND_COLORS[rowIndex],
117
+ };
118
+ }
119
+ }
120
+
121
+ const treeBaseY = groundStart - 1;
122
+ for (const tree of forest.trees) {
123
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
124
+ }
125
+
126
+ const lines = [];
127
+ for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS; y += 1) {
128
+ let line = "";
129
+ for (const cell of buffer[y]) {
130
+ line += cell.color ? chalk.hex(cell.color)(cell.char) : cell.char;
131
+ }
132
+ lines.push(line);
133
+ }
134
+
135
+ lines.push("");
136
+ lines.push(buildStatsLine(forest));
137
+
138
+ return lines.join("\n");
139
+ }
package/src/sprites.js ADDED
@@ -0,0 +1,245 @@
1
+ export const TREE_TYPES = ["oak", "pine", "birch", "willow", "cherry"];
2
+
3
+ const COLORS = {
4
+ canopyDark: "#3f7132",
5
+ canopyMid: "#5b9a4a",
6
+ canopyLight: "#7cc96a",
7
+ canopyDeep: "#2d5b29",
8
+ canopyBright: "#a4e28d",
9
+ trunkDark: "#6f4c2f",
10
+ trunkMid: "#8e6238",
11
+ trunkLight: "#b18552",
12
+ birchTrunk: "#d9d6d2",
13
+ cherryPink: "#de93b8",
14
+ cherryBloom: "#f0b7cf",
15
+ };
16
+
17
+ function parse(template, palette) {
18
+ const lines = template.trim().split("\n");
19
+ const width = Math.max(...lines.map((line) => line.length));
20
+ const rows = lines
21
+ .map((line) => line.padEnd(width, " "))
22
+ .map((line) =>
23
+ Array.from(line, (token) => {
24
+ const color = palette[token] ?? null;
25
+ return color ? ["█", color] : [" ", null];
26
+ }),
27
+ )
28
+ .reverse();
29
+
30
+ return { rows, width };
31
+ }
32
+
33
+ const SPRITES = {
34
+ oak: {
35
+ seed: parse(
36
+ `
37
+ g
38
+ t
39
+ `,
40
+ { g: COLORS.canopyMid, t: COLORS.trunkMid },
41
+ ),
42
+ sapling: parse(
43
+ `
44
+ gg
45
+ ggg
46
+ t
47
+ `,
48
+ { g: COLORS.canopyMid, t: COLORS.trunkMid },
49
+ ),
50
+ young: parse(
51
+ `
52
+ gg
53
+ gGGg
54
+ ggGGgg
55
+ tt
56
+ tt
57
+ `,
58
+ { g: COLORS.canopyMid, G: COLORS.canopyDark, t: COLORS.trunkMid },
59
+ ),
60
+ full: parse(
61
+ `
62
+ gg
63
+ gGGGG
64
+ ggGGGGgg
65
+ gGGGGg
66
+ tt
67
+ tt
68
+ `,
69
+ { g: COLORS.canopyMid, G: COLORS.canopyDark, t: COLORS.trunkMid },
70
+ ),
71
+ },
72
+ pine: {
73
+ seed: parse(
74
+ `
75
+ g
76
+ t
77
+ `,
78
+ { g: COLORS.canopyDeep, t: COLORS.trunkDark },
79
+ ),
80
+ sapling: parse(
81
+ `
82
+ g
83
+ gg
84
+ ggg
85
+ t
86
+ `,
87
+ { g: COLORS.canopyDeep, t: COLORS.trunkDark },
88
+ ),
89
+ young: parse(
90
+ `
91
+ g
92
+ ggg
93
+ gGGGg
94
+ ggGGGG
95
+ t
96
+ t
97
+ `,
98
+ { g: COLORS.canopyDeep, G: COLORS.canopyDark, t: COLORS.trunkDark },
99
+ ),
100
+ full: parse(
101
+ `
102
+ g
103
+ ggg
104
+ gGGGg
105
+ gGGGGGg
106
+ ggGGGGGG
107
+ gGGGGG
108
+ t
109
+ t
110
+ `,
111
+ { g: COLORS.canopyDeep, G: COLORS.canopyDark, t: COLORS.trunkDark },
112
+ ),
113
+ },
114
+ birch: {
115
+ seed: parse(
116
+ `
117
+ g
118
+ b
119
+ `,
120
+ { g: COLORS.canopyLight, b: COLORS.birchTrunk },
121
+ ),
122
+ sapling: parse(
123
+ `
124
+ gg
125
+ ghg
126
+ b
127
+ `,
128
+ { g: COLORS.canopyLight, h: COLORS.canopyBright, b: COLORS.birchTrunk },
129
+ ),
130
+ young: parse(
131
+ `
132
+ hg
133
+ hggg
134
+ ggghhg
135
+ bb
136
+ bb
137
+ `,
138
+ { g: COLORS.canopyLight, h: COLORS.canopyBright, b: COLORS.birchTrunk },
139
+ ),
140
+ full: parse(
141
+ `
142
+ hh
143
+ hgggh
144
+ ggghhgg
145
+ hgggh
146
+ bb
147
+ bb
148
+ `,
149
+ { g: COLORS.canopyLight, h: COLORS.canopyBright, b: COLORS.birchTrunk },
150
+ ),
151
+ },
152
+ willow: {
153
+ seed: parse(
154
+ `
155
+ g
156
+ t
157
+ `,
158
+ { g: COLORS.canopyLight, t: COLORS.trunkMid },
159
+ ),
160
+ sapling: parse(
161
+ `
162
+ ggg
163
+ ggggg
164
+ ttt
165
+ `,
166
+ { g: COLORS.canopyLight, t: COLORS.trunkMid },
167
+ ),
168
+ young: parse(
169
+ `
170
+ gggg
171
+ gggggg
172
+ gg ggg gg
173
+ gg gg
174
+ tt
175
+ tt
176
+ `,
177
+ { g: COLORS.canopyLight, t: COLORS.trunkMid },
178
+ ),
179
+ full: parse(
180
+ `
181
+ ggggg
182
+ gggggggg
183
+ gg ggggg gg
184
+ gg ggg gg
185
+ gg gg
186
+ tt
187
+ tt
188
+ `,
189
+ { g: COLORS.canopyLight, t: COLORS.trunkMid },
190
+ ),
191
+ },
192
+ cherry: {
193
+ seed: parse(
194
+ `
195
+ p
196
+ t
197
+ `,
198
+ { p: COLORS.cherryPink, t: COLORS.trunkLight },
199
+ ),
200
+ sapling: parse(
201
+ `
202
+ pp
203
+ pPp
204
+ t
205
+ `,
206
+ { p: COLORS.cherryBloom, P: COLORS.cherryPink, t: COLORS.trunkLight },
207
+ ),
208
+ young: parse(
209
+ `
210
+ pP
211
+ pPPp
212
+ pPPpPP
213
+ tt
214
+ tt
215
+ `,
216
+ { p: COLORS.cherryBloom, P: COLORS.cherryPink, t: COLORS.trunkLight },
217
+ ),
218
+ full: parse(
219
+ `
220
+ pPp
221
+ pPPPPp
222
+ pPPpPPPp
223
+ pPPPpp
224
+ tt
225
+ tt
226
+ `,
227
+ { p: COLORS.cherryBloom, P: COLORS.cherryPink, t: COLORS.trunkLight },
228
+ ),
229
+ },
230
+ };
231
+
232
+ function getGrowthStage(growth) {
233
+ if (growth < 0.2) return "seed";
234
+ if (growth < 0.5) return "sapling";
235
+ if (growth < 0.8) return "young";
236
+ return "full";
237
+ }
238
+
239
+ export function getSprite(type, growth) {
240
+ const spriteSet = SPRITES[type];
241
+ if (!spriteSet) {
242
+ throw new Error(`Unknown tree type: ${type}`);
243
+ }
244
+ return spriteSet[getGrowthStage(growth)];
245
+ }
package/src/state.js ADDED
@@ -0,0 +1,51 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ function resolveHoneydewDir() {
7
+ return process.env.HONEYDEW_DIR || path.join(os.homedir(), ".honeydew");
8
+ }
9
+
10
+ function resolveForestFile() {
11
+ return path.join(resolveHoneydewDir(), "forest.json");
12
+ }
13
+
14
+ export const HONEYDEW_DIR = resolveHoneydewDir();
15
+ export const FOREST_FILE = resolveForestFile();
16
+
17
+ export function getHoneydewDir() {
18
+ return resolveHoneydewDir();
19
+ }
20
+
21
+ export function getForestFile() {
22
+ return resolveForestFile();
23
+ }
24
+
25
+ export function createEmptyForest() {
26
+ return {
27
+ trees: [],
28
+ totalPrompts: 0,
29
+ createdAt: new Date().toISOString(),
30
+ };
31
+ }
32
+
33
+ export function readForest() {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(resolveForestFile(), "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export function writeForest(state) {
42
+ const dir = resolveHoneydewDir();
43
+ const file = resolveForestFile();
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const tmpFile = path.join(
46
+ dir,
47
+ `forest.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`,
48
+ );
49
+ fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
50
+ fs.renameSync(tmpFile, file);
51
+ }
package/src/viewer.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+
3
+ import { renderFrame } from "./renderer.js";
4
+ import { getForestFile, readForest } from "./state.js";
5
+
6
+ function writeAnsi(code) {
7
+ process.stdout.write(code);
8
+ }
9
+
10
+ function clearScreen() {
11
+ writeAnsi("\x1b[2J\x1b[H");
12
+ }
13
+
14
+ function hideCursor() {
15
+ writeAnsi("\x1b[?25l");
16
+ }
17
+
18
+ function showCursor() {
19
+ writeAnsi("\x1b[?25h");
20
+ }
21
+
22
+ function moveHome() {
23
+ writeAnsi("\x1b[H");
24
+ }
25
+
26
+ function renderForest(forest, twinkleSeed = 0) {
27
+ moveHome();
28
+ process.stdout.write(renderFrame(forest, process.stdout.columns || 80, { twinkleSeed }));
29
+ }
30
+
31
+ function delay(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ async function animateNewTree(forest, newTreeId) {
36
+ const tree = forest.trees.find((entry) => entry.id === newTreeId);
37
+ if (!tree) {
38
+ renderForest(forest);
39
+ return;
40
+ }
41
+
42
+ const originalGrowth = tree.growth;
43
+ const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
44
+ (value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
45
+ );
46
+
47
+ for (let index = 0; index < frames.length; index += 1) {
48
+ tree.growth = frames[index];
49
+ renderForest(forest, index);
50
+ await delay(120);
51
+ }
52
+
53
+ tree.growth = originalGrowth;
54
+ renderForest(forest);
55
+ }
56
+
57
+ export async function viewer() {
58
+ const forestFile = getForestFile();
59
+ let forest = readForest();
60
+
61
+ if (!forest || !fs.existsSync(forestFile)) {
62
+ console.error('No forest found. Run "honeytree init" first.');
63
+ process.exit(1);
64
+ }
65
+
66
+ hideCursor();
67
+ clearScreen();
68
+ renderForest(forest);
69
+
70
+ let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
71
+
72
+ const cleanup = () => {
73
+ showCursor();
74
+ clearScreen();
75
+ console.log(
76
+ `Forest summary: ${forest.trees.length} trees across ${forest.totalPrompts} prompts`,
77
+ );
78
+ process.exit(0);
79
+ };
80
+
81
+ process.on("SIGINT", cleanup);
82
+ process.on("SIGTERM", cleanup);
83
+ process.stdout.on("resize", () => {
84
+ clearScreen();
85
+ renderForest(forest);
86
+ });
87
+
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);
102
+ }
103
+ }, 100);
104
+ });
105
+ }