honeytree 1.2.0 → 1.2.1

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,404 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { execSync } from "node:child_process";
7
+ import { getAuth, isLoggedIn } from "../auth.js";
8
+ import { getRewards, syncRewards, uncelebratedUnlocked, markCelebrated } from "../rewards.js";
9
+ import UnlockCelebration from "./UnlockCelebration.js";
10
+
11
+ import ForestScene from "./ForestScene.js";
12
+ import StatsBar from "./StatsBar.js";
13
+ import TreeInfoPopup from "./TreeInfoPopup.js";
14
+
15
+ import { getForestFile, readForest, writeForest } from "../state.js";
16
+ import { migrateLayout } from "../migrate.js";
17
+ import { getVirtualWidth } from "../plant.js";
18
+ import { getAnimationFrames } from "../animation.js";
19
+ import { createForestWatcher } from "../viewer2d.js";
20
+ import { readActiveSession, isStale } from "../session.js";
21
+ import { readNewTokens } from "../transcript.js";
22
+ import { tokensToTree } from "../growth.js";
23
+ import LiveTree from "./LiveTree.js";
24
+
25
+ const h = React.createElement;
26
+ const PAN_STEP = 4;
27
+
28
+ export default function ForestApp() {
29
+ const { exit } = useApp();
30
+ const { stdout } = useStdout();
31
+
32
+ const forestFile = getForestFile();
33
+
34
+ const [forest, setForest] = useState(() => {
35
+ const f = readForest();
36
+ if (!f || !fs.existsSync(forestFile)) {
37
+ console.error('No forest found. Run "honeytree init" first.');
38
+ process.exit(1);
39
+ }
40
+ if (f && (!f.layoutVersion || f.layoutVersion < 2)) {
41
+ const termWidth = stdout?.columns || 80;
42
+ migrateLayout(f, termWidth);
43
+ }
44
+ return f;
45
+ });
46
+
47
+ const [viewportX, setViewportX] = useState(forest.viewportX || 0);
48
+ const [windTick, setWindTick] = useState(0);
49
+ const [termWidth, setTermWidth] = useState(stdout?.columns || 80);
50
+ const [selectedTreeIdx, setSelectedTreeIdx] = useState(-1);
51
+ const [showPopup, setShowPopup] = useState(false);
52
+ const [animating, setAnimating] = useState(false);
53
+ const [spriteOverride, setSpriteOverride] = useState(null);
54
+ const [groundOverlay, setGroundOverlay] = useState(null);
55
+ const [groundPulse, setGroundPulse] = useState(false);
56
+ const [milestonePrompt, setMilestonePrompt] = useState(null);
57
+ const [rewards, setRewards] = useState(() => getRewards());
58
+ const [celebrationQueue, setCelebrationQueue] = useState(() => uncelebratedUnlocked());
59
+
60
+ const [activeTree, setActiveTree] = useState(null);
61
+ const [liveTokens, setLiveTokens] = useState(0);
62
+ const tailRef = useRef({ path: null, offset: 0, tokens: 0, startedAt: 0 });
63
+
64
+ const ignoreNextChange = useRef(false);
65
+ const lastMaxId = useRef(forest.trees.reduce((max, t) => Math.max(max, t.id), 0));
66
+ const lastTotalPrompts = useRef(forest.totalPrompts);
67
+ const forestRef = useRef(forest);
68
+ forestRef.current = forest;
69
+ const viewportXRef = useRef(viewportX);
70
+ viewportXRef.current = viewportX;
71
+
72
+ // Fetch rewards from server (async, non-blocking)
73
+ useEffect(() => {
74
+ if (isLoggedIn()) {
75
+ syncRewards().then((r) => {
76
+ if (r) {
77
+ setRewards(r);
78
+ setCelebrationQueue(uncelebratedUnlocked());
79
+ }
80
+ }).catch(() => {});
81
+ }
82
+ }, []);
83
+
84
+ // Sync width to disk
85
+ const syncWidth = useCallback(() => {
86
+ const cols = stdout?.columns || 80;
87
+ const f = forestRef.current;
88
+ if (f.viewerWidth !== cols) {
89
+ f.viewerWidth = cols;
90
+ ignoreNextChange.current = true;
91
+ writeForest(f);
92
+ }
93
+ }, [stdout]);
94
+
95
+ useEffect(() => {
96
+ syncWidth();
97
+ }, [syncWidth]);
98
+
99
+ // Clamp viewport helper
100
+ const clampViewport = useCallback((x) => {
101
+ const vw = getVirtualWidth(forestRef.current.trees.length, termWidth);
102
+ return Math.max(0, Math.min(x, Math.max(0, vw - termWidth)));
103
+ }, [termWidth]);
104
+
105
+ // Get visible trees in viewport for selection
106
+ const getVisibleTrees = useCallback(() => {
107
+ const vpEnd = viewportX + termWidth;
108
+ return forest.trees
109
+ .filter((t) => t.x >= viewportX && t.x < vpEnd)
110
+ .sort((a, b) => a.x - b.x);
111
+ }, [forest, viewportX, termWidth]);
112
+
113
+ // Wind interval
114
+ useEffect(() => {
115
+ const id = setInterval(() => {
116
+ if (!animating) {
117
+ setWindTick((t) => t + 1);
118
+ }
119
+ }, 2500);
120
+ return () => clearInterval(id);
121
+ }, [animating]);
122
+
123
+ // Live growth: tail the active Claude Code session transcript.
124
+ useEffect(() => {
125
+ const id = setInterval(() => {
126
+ const s = readActiveSession();
127
+ if (!s || isStale(s)) {
128
+ if (tailRef.current.path) {
129
+ tailRef.current = { path: null, offset: 0, tokens: 0, startedAt: 0 };
130
+ setActiveTree(null);
131
+ setLiveTokens(0);
132
+ }
133
+ return;
134
+ }
135
+ if (tailRef.current.path !== s.transcript_path || tailRef.current.startedAt !== s.turnStartedAt) {
136
+ tailRef.current = {
137
+ path: s.transcript_path,
138
+ offset: s.baselineOffset || 0,
139
+ tokens: 0,
140
+ startedAt: s.turnStartedAt,
141
+ };
142
+ }
143
+ const { tokens, newOffset } = readNewTokens(tailRef.current.path, tailRef.current.offset);
144
+ tailRef.current.offset = newOffset;
145
+ tailRef.current.tokens += tokens;
146
+ const shape = tokensToTree(tailRef.current.tokens);
147
+ setLiveTokens(tailRef.current.tokens);
148
+ setActiveTree({
149
+ id: -1,
150
+ type: s.type || "oak",
151
+ x: typeof s.x === "number" ? s.x : 0,
152
+ growth: shape.growth,
153
+ heightBonus: shape.heightBonus,
154
+ variant: s.variant ?? null,
155
+ plantedAt: new Date().toISOString(),
156
+ });
157
+ }, 1000);
158
+ return () => clearInterval(id);
159
+ }, []);
160
+
161
+ // Resize handler
162
+ useEffect(() => {
163
+ const onResize = () => {
164
+ const cols = stdout?.columns || 80;
165
+ setTermWidth(cols);
166
+ syncWidth();
167
+ };
168
+ stdout?.on("resize", onResize);
169
+ return () => stdout?.off("resize", onResize);
170
+ }, [stdout, syncWidth]);
171
+
172
+ // Animate new tree
173
+ const animateNewTree = useCallback(async (updatedForest, newTreeId) => {
174
+ const tree = updatedForest.trees.find((t) => t.id === newTreeId);
175
+ if (!tree) return;
176
+
177
+ const FRAME_COUNT = 40;
178
+ const FRAME_DELAY = 125;
179
+
180
+ const frames = getAnimationFrames(tree.type, tree.growth, FRAME_COUNT);
181
+
182
+ for (let i = 0; i < frames.length; i++) {
183
+ const frameData = frames[i];
184
+ setSpriteOverride({ treeId: tree.id, sprite: frameData.sprite });
185
+ setGroundPulse(frameData.groundPulse ?? false);
186
+ if (frameData.groundOverlay) {
187
+ setGroundOverlay({ treeX: tree.x, overlays: frameData.groundOverlay });
188
+ } else {
189
+ setGroundOverlay(null);
190
+ }
191
+ setWindTick(i);
192
+ await new Promise((r) => setTimeout(r, FRAME_DELAY));
193
+ }
194
+
195
+ setSpriteOverride(null);
196
+ setGroundOverlay(null);
197
+ setGroundPulse(false);
198
+ }, []);
199
+
200
+ // File watcher + polling for forest changes
201
+ useEffect(() => {
202
+ const checkForUpdates = async () => {
203
+ if (animating) return;
204
+ if (ignoreNextChange.current) {
205
+ ignoreNextChange.current = false;
206
+ return;
207
+ }
208
+ const updated = readForest();
209
+ if (!updated) return;
210
+ if (updated.totalPrompts === lastTotalPrompts.current) return;
211
+
212
+ const nextMaxId = updated.trees.reduce((max, t) => Math.max(max, t.id), 0);
213
+ lastTotalPrompts.current = updated.totalPrompts;
214
+ setForest(updated);
215
+ forestRef.current = updated;
216
+ setActiveTree(null);
217
+ setLiveTokens(0);
218
+ tailRef.current = { path: null, offset: 0, tokens: 0, startedAt: 0 };
219
+
220
+ // Check for milestone flag
221
+ try {
222
+ const milestoneFile = path.join(os.homedir(), ".honeydew", "milestone.json");
223
+ if (fs.existsSync(milestoneFile)) {
224
+ const milestone = JSON.parse(fs.readFileSync(milestoneFile, "utf8"));
225
+ setMilestonePrompt(milestone);
226
+ }
227
+ } catch {}
228
+
229
+ if (nextMaxId > lastMaxId.current) {
230
+ lastMaxId.current = nextMaxId;
231
+ const newTree = updated.trees.find((t) => t.id === nextMaxId);
232
+ if (newTree) {
233
+ const tw = stdout?.columns || 80;
234
+ const vw = getVirtualWidth(updated.trees.length, tw);
235
+ const newVpx = Math.max(0, Math.min(newTree.x - Math.floor(tw / 2), Math.max(0, vw - tw)));
236
+ setViewportX(newVpx);
237
+ }
238
+ setAnimating(true);
239
+ await animateNewTree(updated, nextMaxId);
240
+ setAnimating(false);
241
+ }
242
+ };
243
+
244
+ const watcher = createForestWatcher(forestFile, () => checkForUpdates());
245
+
246
+ let lastMtime = 0;
247
+ try { lastMtime = fs.statSync(forestFile).mtimeMs; } catch {}
248
+
249
+ const pollId = setInterval(() => {
250
+ try {
251
+ const mtime = fs.statSync(forestFile).mtimeMs;
252
+ if (mtime !== lastMtime) {
253
+ lastMtime = mtime;
254
+ checkForUpdates();
255
+ }
256
+ } catch {}
257
+ }, 800);
258
+
259
+ return () => {
260
+ if (watcher) try { watcher.close(); } catch {}
261
+ clearInterval(pollId);
262
+ };
263
+ }, [forestFile, animating, animateNewTree, stdout]);
264
+
265
+ // Save viewport on unmount
266
+ useEffect(() => {
267
+ return () => {
268
+ const f = forestRef.current;
269
+ f.viewportX = viewportXRef.current;
270
+ ignoreNextChange.current = true;
271
+ writeForest(f);
272
+ };
273
+ }, []);
274
+
275
+ // Keyboard input
276
+ useInput((input, key) => {
277
+ if (celebrationQueue.length > 0) return;
278
+
279
+ if (milestonePrompt) {
280
+ const milestoneFile = path.join(os.homedir(), ".honeydew", "milestone.json");
281
+ if (input === "y") {
282
+ // Open checkout in browser
283
+ const auth = getAuth();
284
+ if (auth && auth.access_token) {
285
+ fetch("https://tryhoney.xyz/api/checkout", {
286
+ method: "POST",
287
+ headers: {
288
+ "Content-Type": "application/json",
289
+ Authorization: `Bearer ${auth.access_token}`,
290
+ },
291
+ })
292
+ .then((r) => r.json())
293
+ .then(({ url }) => {
294
+ if (url) {
295
+ try { execSync(`open "${url}"`); } catch {
296
+ try { execSync(`xdg-open "${url}"`); } catch {}
297
+ }
298
+ }
299
+ })
300
+ .catch(() => {});
301
+ }
302
+ try { fs.unlinkSync(milestoneFile); } catch {}
303
+ setMilestonePrompt(null);
304
+ } else if (input === "n") {
305
+ try { fs.unlinkSync(milestoneFile); } catch {}
306
+ setMilestonePrompt(null);
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (showPopup) {
312
+ if (key.escape) {
313
+ setShowPopup(false);
314
+ }
315
+ return;
316
+ }
317
+
318
+ if (input === "q") {
319
+ const f = forestRef.current;
320
+ f.viewportX = viewportXRef.current;
321
+ ignoreNextChange.current = true;
322
+ writeForest(f);
323
+ exit();
324
+ return;
325
+ }
326
+
327
+ if (key.leftArrow) {
328
+ setViewportX((x) => clampViewport(x - PAN_STEP));
329
+ return;
330
+ }
331
+ if (key.rightArrow) {
332
+ setViewportX((x) => clampViewport(x + PAN_STEP));
333
+ return;
334
+ }
335
+
336
+ if (key.upArrow) {
337
+ const visible = getVisibleTrees();
338
+ if (visible.length === 0) return;
339
+ setSelectedTreeIdx((prev) => {
340
+ if (prev <= 0) return visible.length - 1;
341
+ return prev - 1;
342
+ });
343
+ return;
344
+ }
345
+ if (key.downArrow || key.tab) {
346
+ const visible = getVisibleTrees();
347
+ if (visible.length === 0) return;
348
+ setSelectedTreeIdx((prev) => {
349
+ if (prev < 0 || prev >= visible.length - 1) return 0;
350
+ return prev + 1;
351
+ });
352
+ return;
353
+ }
354
+
355
+ if (key.return) {
356
+ const visible = getVisibleTrees();
357
+ if (selectedTreeIdx >= 0 && selectedTreeIdx < visible.length) {
358
+ setShowPopup(true);
359
+ }
360
+ return;
361
+ }
362
+ });
363
+
364
+ const visibleTrees = getVisibleTrees();
365
+ const selectedTree = selectedTreeIdx >= 0 && selectedTreeIdx < visibleTrees.length
366
+ ? visibleTrees[selectedTreeIdx]
367
+ : null;
368
+
369
+ const liveForest = activeTree
370
+ ? { ...forest, trees: [...forest.trees, activeTree] }
371
+ : forest;
372
+
373
+ if (celebrationQueue.length > 0) {
374
+ const current = celebrationQueue[0];
375
+ return h(UnlockCelebration, {
376
+ varietyKey: current,
377
+ onDismiss: () => {
378
+ markCelebrated(current);
379
+ setCelebrationQueue((q) => q.slice(1));
380
+ },
381
+ });
382
+ }
383
+
384
+ return h(Box, { flexDirection: "column" },
385
+ h(ForestScene, {
386
+ forest: liveForest,
387
+ viewportX,
388
+ windTick,
389
+ termWidth,
390
+ spriteOverride,
391
+ groundOverlay,
392
+ groundPulse,
393
+ rewards,
394
+ }),
395
+ h(StatsBar, { forest, viewportX, termWidth, rewards }),
396
+ activeTree ? h(LiveTree, { tokens: liveTokens }) : null,
397
+ showPopup && selectedTree ? h(TreeInfoPopup, { tree: selectedTree }) : null,
398
+ milestonePrompt ? h(Box, { flexDirection: "column", paddingX: 1 },
399
+ h(Text, { color: "green", bold: true },
400
+ `${milestonePrompt.totalPrompts} virtual trees! You've unlocked a real tree planting ($1). Plant one? (y/n)`
401
+ ),
402
+ ) : null,
403
+ );
404
+ }
@@ -0,0 +1,26 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text } from "ink";
3
+
4
+ import { renderFrame } from "../renderer.js";
5
+ import { getVirtualWidth } from "../plant.js";
6
+
7
+ const h = React.createElement;
8
+
9
+ export default function ForestScene({ forest, viewportX, windTick, termWidth, spriteOverride, groundOverlay, groundPulse, rewards }) {
10
+ const frame = useMemo(() => {
11
+ const vw = getVirtualWidth(forest.trees.length, termWidth);
12
+ return renderFrame(forest, termWidth, {
13
+ twinkleSeed: windTick,
14
+ viewportX,
15
+ virtualWidth: vw,
16
+ windTick,
17
+ includeStats: false,
18
+ spriteOverride: spriteOverride ?? undefined,
19
+ groundOverlay: groundOverlay ?? undefined,
20
+ groundPulse: groundPulse ?? false,
21
+ rewards: rewards ?? undefined,
22
+ });
23
+ }, [forest, viewportX, windTick, termWidth, spriteOverride, groundOverlay, groundPulse, rewards]);
24
+
25
+ return h(Text, null, frame);
26
+ }
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ const h = React.createElement;
5
+
6
+ // Small status line shown while a live turn is streaming.
7
+ export default function LiveTree({ tokens }) {
8
+ return h(
9
+ Box,
10
+ {},
11
+ h(Text, { color: "green" }, "● live "),
12
+ h(Text, { color: "yellow" }, `· ${tokens.toLocaleString()} tok`),
13
+ );
14
+ }
@@ -0,0 +1,70 @@
1
+ import React, { useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ import { getStatsData } from "../renderer.js";
5
+ import { getVirtualWidth } from "../plant.js";
6
+
7
+ const h = React.createElement;
8
+
9
+ const STATS_ACCENT = "#f5a50b";
10
+ const STATS_TEXT = "#8e8a84";
11
+ const STATS_WARN = "#c4653a";
12
+ const STREAK_COLOR = "#e8a33a";
13
+ const BAR_FILL = "#6cb95e";
14
+ const BAR_EMPTY = "#3d3d3d";
15
+
16
+ export default function StatsBar({ forest, viewportX, termWidth, rewards }) {
17
+ const vw = getVirtualWidth(forest.trees.length, termWidth);
18
+ const stats = useMemo(
19
+ () => getStatsData(forest, viewportX, vw, termWidth),
20
+ [forest, viewportX, vw, termWidth],
21
+ );
22
+
23
+ const barWidth = 12;
24
+ const filledWidth = Math.max(0, Math.min(barWidth, Math.round(stats.progress * barWidth)));
25
+
26
+ // Minimap
27
+ let minimap = null;
28
+ if (stats.virtualWidth > stats.termWidth) {
29
+ const mapWidth = 12;
30
+ const viewFraction = stats.termWidth / stats.virtualWidth;
31
+ const thumbWidth = Math.max(1, Math.round(viewFraction * mapWidth));
32
+ const maxOffset = stats.virtualWidth - stats.termWidth;
33
+ const thumbPos = maxOffset > 0
34
+ ? Math.round((stats.viewportX / maxOffset) * (mapWidth - thumbWidth))
35
+ : 0;
36
+ const mapBar =
37
+ "─".repeat(thumbPos) +
38
+ "═".repeat(thumbWidth) +
39
+ "─".repeat(mapWidth - thumbPos - thumbWidth);
40
+ minimap = h(Text, null,
41
+ h(Text, { color: STATS_TEXT }, " ["),
42
+ h(Text, { color: BAR_FILL }, mapBar),
43
+ h(Text, { color: STATS_TEXT }, "]"),
44
+ );
45
+ }
46
+
47
+ const streakSegment = stats.wilt > 0
48
+ ? h(Text, { color: STATS_WARN }, `wilting (${stats.idleDays}d idle)`)
49
+ : stats.streak > 0
50
+ ? h(Text, { color: STREAK_COLOR }, `${stats.streak}-day streak`)
51
+ : h(Text, { color: STATS_TEXT }, "no streak");
52
+
53
+ return h(Box, { flexDirection: "column" },
54
+ h(Box, null,
55
+ h(Text, { color: STATS_ACCENT }, " honeytree"),
56
+ h(Text, { color: STATS_TEXT }, ` · ${stats.treeCount} tree${stats.treeCount === 1 ? "" : "s"} · `),
57
+ streakSegment,
58
+ h(Text, { color: STATS_TEXT }, " · "),
59
+ h(Text, { color: BAR_FILL }, "█".repeat(filledWidth)),
60
+ h(Text, { color: BAR_EMPTY }, "░".repeat(barWidth - filledWidth)),
61
+ h(Text, { color: STATS_TEXT }, ` next: ${stats.nextTreeType}`),
62
+ h(Text, { color: "#555555" }, ` [${stats.biomeName}]`),
63
+ minimap,
64
+ ),
65
+ h(Box, null,
66
+ h(Text, { color: "#555555" }, " ← → pan · ↑↓ select tree · enter info · q quit · "),
67
+ h(Text, { color: STATS_ACCENT }, "honeytree badge"),
68
+ ),
69
+ );
70
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ const h = React.createElement;
5
+
6
+ export default function TreeInfoPopup({ tree }) {
7
+ if (!tree) return null;
8
+
9
+ const growthPct = Math.round(tree.growth * 100);
10
+ const plantedDate = tree.plantedAt
11
+ ? new Date(tree.plantedAt).toLocaleDateString()
12
+ : "unknown";
13
+
14
+ let age = "";
15
+ if (tree.plantedAt) {
16
+ const days = Math.floor((Date.now() - new Date(tree.plantedAt).getTime()) / (24 * 60 * 60 * 1000));
17
+ age = days === 0 ? "today" : days === 1 ? "1 day" : `${days} days`;
18
+ }
19
+
20
+ return h(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#f5a50b", paddingX: 1 },
21
+ h(Text, { bold: true, color: "#f5a50b" }, ` ${tree.type} `),
22
+ h(Text, { color: "#8e8a84" }, ` Growth: ${growthPct}%`),
23
+ h(Text, { color: "#8e8a84" }, ` Planted: ${plantedDate}`),
24
+ age ? h(Text, { color: "#8e8a84" }, ` Age: ${age}`) : null,
25
+ h(Text, { color: "#8e8a84" }, ` ID: ${tree.id}`),
26
+ h(Text, { dimColor: true }, ` [esc] close`),
27
+ );
28
+ }
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { VARIETIES } from "../varieties.js";
4
+
5
+ const h = React.createElement;
6
+
7
+ // ASCII reveal art per variety (screenshot-worthy, terminal-safe).
8
+ const ART = {
9
+ cherry: " .::.\n.:(@@):.\n :(@@): \n || ",
10
+ pine: " ^\n /^\\\n /^^^\\\n /^^^^^\\\n ||| ",
11
+ oak: " ___\n (###)\n (#####)\n (###)\n ||| ",
12
+ ancient: " *___*\n (#####)\n(#######)\n (#####)\n ||||| ",
13
+ mythic: " *. .*\n .*###*.\n*#######*\n .*###*.\n ||||| ",
14
+ };
15
+
16
+ export function celebrationFor(key) {
17
+ const v = VARIETIES.find((x) => x.key === key);
18
+ return {
19
+ label: v ? v.label : key,
20
+ art: ART[key] || " ###\n (###)\n |||",
21
+ };
22
+ }
23
+
24
+ // Full-screen celebration. Calls onDismiss() when the user presses any key.
25
+ export default function UnlockCelebration({ varietyKey, onDismiss }) {
26
+ const { label, art } = celebrationFor(varietyKey);
27
+ useInput(() => onDismiss());
28
+
29
+ return h(
30
+ Box,
31
+ { flexDirection: "column", alignItems: "center", justifyContent: "center", height: 18, width: "100%" },
32
+ h(Text, { color: "#f5a50b", bold: true }, "✦ NEW TREE VARIETY UNLOCKED ✦"),
33
+ h(Box, { height: 1 }),
34
+ h(Text, { color: "#b388ff" }, art),
35
+ h(Box, { height: 1 }),
36
+ h(Text, { bold: true }, label),
37
+ h(Box, { height: 1 }),
38
+ h(Text, { dimColor: true }, "Screenshot this — then press any key to enter your forest")
39
+ );
40
+ }
package/src/growth.js ADDED
@@ -0,0 +1,32 @@
1
+ // Pure mapping from a turn's output-token count to a tree's shape.
2
+ // Tunable: change these to reshape the forest's "honesty" curve.
3
+ export const GROWTH = {
4
+ SAPLING_MAX: 250,
5
+ FULL_TOKENS: 1500,
6
+ MONSTER_TOKENS: 4000,
7
+ MAX_HEIGHT_BONUS: 4,
8
+ };
9
+
10
+ export function tokensToTree(tokens) {
11
+ const n = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
12
+
13
+ let growth;
14
+ if (n < GROWTH.SAPLING_MAX) {
15
+ growth = 0.15 + (n / GROWTH.SAPLING_MAX) * 0.2; // 0.15 .. 0.35
16
+ } else if (n < GROWTH.FULL_TOKENS) {
17
+ const r = (n - GROWTH.SAPLING_MAX) / (GROWTH.FULL_TOKENS - GROWTH.SAPLING_MAX);
18
+ growth = 0.35 + r * 0.65; // 0.35 .. 1.0
19
+ } else {
20
+ growth = 1;
21
+ }
22
+
23
+ let heightBonus = 0;
24
+ if (n > GROWTH.FULL_TOKENS) {
25
+ const over =
26
+ (n - GROWTH.FULL_TOKENS) / (GROWTH.MONSTER_TOKENS - GROWTH.FULL_TOKENS);
27
+ heightBonus = Math.min(GROWTH.MAX_HEIGHT_BONUS, Math.max(1, Math.ceil(over * GROWTH.MAX_HEIGHT_BONUS)));
28
+ }
29
+
30
+ // Variant (species/look) is owned by the variety system, not token count.
31
+ return { growth: Math.min(1, Math.round(growth * 100) / 100), heightBonus, variant: null };
32
+ }
package/src/init.js CHANGED
@@ -20,7 +20,17 @@ const HONEYDEW_STOP_HOOK = {
20
20
  hooks: [
21
21
  {
22
22
  type: "command",
23
- command: "honeytree plant",
23
+ command: "honeytree __tick",
24
+ },
25
+ ],
26
+ };
27
+
28
+ const HONEYDEW_SESSION_HOOK = {
29
+ matcher: "",
30
+ hooks: [
31
+ {
32
+ type: "command",
33
+ command: "honeytree __session",
24
34
  },
25
35
  ],
26
36
  };
@@ -28,11 +38,34 @@ const HONEYDEW_STOP_HOOK = {
28
38
  function hasHoneydewHook(settings) {
29
39
  return (
30
40
  settings?.hooks?.Stop?.some((entry) =>
31
- entry?.hooks?.some((hook) => hook?.command === "honeytree plant"),
41
+ entry?.hooks?.some((hook) => hook?.command === "honeytree __tick"),
32
42
  ) ?? false
33
43
  );
34
44
  }
35
45
 
46
+ function hasSessionHook(settings) {
47
+ return (
48
+ settings?.hooks?.UserPromptSubmit?.some((entry) =>
49
+ entry?.hooks?.some((hook) => hook?.command === "honeytree __session"),
50
+ ) ?? false
51
+ );
52
+ }
53
+
54
+ // Rewrite any legacy `honeytree plant` Stop hook to the new hidden `__tick`.
55
+ // Returns true if anything changed.
56
+ function migrateLegacyHook(settings) {
57
+ let changed = false;
58
+ for (const entry of settings?.hooks?.Stop ?? []) {
59
+ for (const hook of entry?.hooks ?? []) {
60
+ if (hook?.command === "honeytree plant") {
61
+ hook.command = "honeytree __tick";
62
+ changed = true;
63
+ }
64
+ }
65
+ }
66
+ return changed;
67
+ }
68
+
36
69
  export async function init() {
37
70
  const honeydewDir = getHoneydewDir();
38
71
  fs.mkdirSync(honeydewDir, { recursive: true });
@@ -72,6 +105,12 @@ export async function init() {
72
105
  settings.hooks ??= {};
73
106
  settings.hooks.Stop ??= [];
74
107
 
108
+ const migrated = migrateLegacyHook(settings);
109
+ if (migrated) {
110
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
111
+ console.log(`Migrated legacy honeytree hook to __tick in ${settingsPath}`);
112
+ }
113
+
75
114
  if (hasHoneydewHook(settings)) {
76
115
  console.log(`Claude Code hook already configured in ${settingsPath}`);
77
116
  } else {
@@ -80,6 +119,13 @@ export async function init() {
80
119
  console.log(`Added honeytree Stop hook to ${settingsPath}`);
81
120
  }
82
121
 
122
+ settings.hooks.UserPromptSubmit ??= [];
123
+ if (!hasSessionHook(settings)) {
124
+ settings.hooks.UserPromptSubmit.push(HONEYDEW_SESSION_HOOK);
125
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
126
+ console.log(`Added honeytree UserPromptSubmit hook to ${settingsPath}`);
127
+ }
128
+
83
129
  console.log("");
84
130
  console.log("Setup complete.");
85
131
  console.log("Run `honeytree` in a separate terminal to watch the forest grow.");