honeytree 1.1.6 → 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,151 @@
1
+ import { ANIMATION_KEYFRAMES } from "./animation-keyframes.js";
2
+ import { getSprite } from "./sprites.js";
3
+
4
+ const CHAR_ORDER = [" ", "░", "▒", "█"];
5
+
6
+ function charIndex(ch) {
7
+ const idx = CHAR_ORDER.indexOf(ch);
8
+ return idx >= 0 ? idx : 0;
9
+ }
10
+
11
+ function interpolateChar(ch1, ch2, t) {
12
+ const i1 = charIndex(ch1);
13
+ const i2 = charIndex(ch2);
14
+ const idx = Math.round(i1 + (i2 - i1) * t);
15
+ return CHAR_ORDER[Math.max(0, Math.min(CHAR_ORDER.length - 1, idx))];
16
+ }
17
+
18
+ function parseHex(hex) {
19
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
20
+ return {
21
+ r: parseInt(h.slice(0, 2), 16),
22
+ g: parseInt(h.slice(2, 4), 16),
23
+ b: parseInt(h.slice(4, 6), 16),
24
+ };
25
+ }
26
+
27
+ function toHex({ r, g, b }) {
28
+ const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
29
+ return `#${c(r)}${c(g)}${c(b)}`;
30
+ }
31
+
32
+ function lerpColor(hex1, hex2, t) {
33
+ const c1 = parseHex(hex1);
34
+ const c2 = parseHex(hex2);
35
+ return toHex({
36
+ r: c1.r + (c2.r - c1.r) * t,
37
+ g: c1.g + (c2.g - c1.g) * t,
38
+ b: c1.b + (c2.b - c1.b) * t,
39
+ });
40
+ }
41
+
42
+ function interpolateSprite(sprite1, sprite2, t) {
43
+ const rows = sprite1.rows.map((row, r) =>
44
+ row.map(([ch1, col1], c) => {
45
+ const [ch2, col2] = sprite2.rows[r][c];
46
+ if (!col1 && !col2) return [" ", null];
47
+ if (!col1 && col2) {
48
+ const ch = interpolateChar(" ", ch2, t);
49
+ if (ch === " ") return [" ", null];
50
+ return [ch, col2];
51
+ }
52
+ if (col1 && !col2) {
53
+ const ch = interpolateChar(ch1, " ", t);
54
+ if (ch === " ") return [" ", null];
55
+ return [ch, col1];
56
+ }
57
+ const ch = interpolateChar(ch1, ch2, t);
58
+ const col = lerpColor(col1, col2, t);
59
+ return [ch, col];
60
+ }),
61
+ );
62
+ return { rows, width: sprite1.width };
63
+ }
64
+
65
+ function brightenSprite(sprite, amount) {
66
+ const rows = sprite.rows.map((row) =>
67
+ row.map(([ch, col]) => {
68
+ if (!col) return [ch, col];
69
+ const c = parseHex(col);
70
+ return [ch, toHex({
71
+ r: Math.min(255, c.r + (255 - c.r) * amount),
72
+ g: Math.min(255, c.g + (255 - c.g) * amount),
73
+ b: Math.min(255, c.b + (255 - c.b) * amount),
74
+ })];
75
+ }),
76
+ );
77
+ return { rows, width: sprite.width };
78
+ }
79
+
80
+ export function getAnimationFrames(type, growth, frameCount = 40) {
81
+ const allKeyframes = ANIMATION_KEYFRAMES[type];
82
+ if (!allKeyframes) throw new Error(`No animation keyframes for type: ${type}`);
83
+
84
+ const finalSprite = getSprite(type, growth);
85
+
86
+ let keyframes;
87
+ if (growth >= 0.8) {
88
+ keyframes = allKeyframes;
89
+ } else {
90
+ let maxKF;
91
+ if (growth < 0.2) maxKF = 3;
92
+ else if (growth < 0.5) maxKF = 6;
93
+ else maxKF = 8;
94
+
95
+ keyframes = allKeyframes.slice(0, maxKF);
96
+ const lastTime = keyframes[keyframes.length - 1].time;
97
+ const timeScale = 4.0 / Math.max(lastTime, 0.1);
98
+
99
+ keyframes = keyframes.map((kf) => ({ ...kf, time: kf.time * timeScale }));
100
+ keyframes.push({ time: 4.0, sprite: finalSprite });
101
+ keyframes.push({ time: 4.5, sprite: brightenSprite(finalSprite, 0.2) });
102
+ }
103
+
104
+ const totalDuration = 5.0;
105
+ const frames = [];
106
+
107
+ for (let i = 0; i < frameCount; i++) {
108
+ const t = (i / (frameCount - 1)) * totalDuration;
109
+
110
+ let kfBefore = keyframes[0];
111
+ let kfAfter = keyframes[0];
112
+
113
+ for (let k = 0; k < keyframes.length - 1; k++) {
114
+ if (t >= keyframes[k].time && t <= keyframes[k + 1].time) {
115
+ kfBefore = keyframes[k];
116
+ kfAfter = keyframes[k + 1];
117
+ break;
118
+ }
119
+ if (k === keyframes.length - 2) {
120
+ kfBefore = keyframes[keyframes.length - 1];
121
+ kfAfter = keyframes[keyframes.length - 1];
122
+ }
123
+ }
124
+
125
+ const segmentDuration = kfAfter.time - kfBefore.time;
126
+ const segmentT = segmentDuration > 0 ? (t - kfBefore.time) / segmentDuration : 1;
127
+ const clampedT = Math.max(0, Math.min(1, segmentT));
128
+
129
+ const sprite = interpolateSprite(kfBefore.sprite, kfAfter.sprite, clampedT);
130
+
131
+ let groundOverlay = null;
132
+ if (t < 0.5 && kfBefore.groundEffect) {
133
+ const ge = kfBefore.groundEffect;
134
+ groundOverlay = [];
135
+ for (let dx = -ge.radius; dx <= ge.radius; dx++) {
136
+ const intensity = 1 - Math.abs(dx) / ge.radius;
137
+ if (intensity > 0.3) {
138
+ groundOverlay.push({ dx, char: "░", color: ge.color });
139
+ }
140
+ }
141
+ }
142
+
143
+ if (i === frameCount - 1) {
144
+ frames.push({ sprite: finalSprite, groundOverlay: null, groundPulse: false });
145
+ } else {
146
+ frames.push({ sprite, groundOverlay, groundPulse: i < 3 });
147
+ }
148
+ }
149
+
150
+ return frames;
151
+ }
@@ -0,0 +1,21 @@
1
+ const TREE = `
2
+ /\\
3
+ / \\
4
+ / \\
5
+ /______\\
6
+ ||
7
+ ||
8
+ `;
9
+
10
+ const TREE_BLOSSOM = `
11
+ .::.
12
+ .:(@@):.
13
+ :(@@@@@@):
14
+ ':(@@):'
15
+ ||
16
+ ||
17
+ `;
18
+
19
+ export function asciiTree(hasBloomer) {
20
+ return hasBloomer ? TREE_BLOSSOM : TREE;
21
+ }
package/src/auth.js ADDED
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ const AUTH_DIR = path.join(os.homedir(), ".honeydew");
6
+ const AUTH_FILE = path.join(AUTH_DIR, "auth.json");
7
+
8
+ export function getAuth() {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function saveAuth(data) {
17
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
18
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2));
19
+ }
20
+
21
+ export function clearAuth() {
22
+ try {
23
+ fs.unlinkSync(AUTH_FILE);
24
+ } catch {}
25
+ }
26
+
27
+ export function isLoggedIn() {
28
+ const auth = getAuth();
29
+ return !!(auth && auth.access_token);
30
+ }
31
+
32
+ export async function loginWithDevice(apiUrl = process.env.HONEYTREE_API_URL || "https://tryhoney.xyz") {
33
+ const res = await fetch(`${apiUrl}/api/auth/device`, { method: "POST" });
34
+ const contentType = res.headers.get("content-type") || "";
35
+ if (!contentType.includes("application/json")) {
36
+ console.error(" Server returned unexpected response. Is the Honeytree web app running?");
37
+ return false;
38
+ }
39
+ const { device_code, user_code, interval } = await res.json();
40
+
41
+ console.log();
42
+ console.log(` Your code: ${user_code}`);
43
+ console.log();
44
+ console.log(" Enter this code on your Honeytree dashboard to link your terminal.");
45
+ console.log(" Waiting...");
46
+
47
+ while (true) {
48
+ await new Promise((r) => setTimeout(r, (interval || 5) * 1000));
49
+
50
+ try {
51
+ const pollRes = await fetch(`${apiUrl}/api/auth/device/poll`, {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({ device_code }),
55
+ });
56
+
57
+ const pollContentType = pollRes.headers.get("content-type") || "";
58
+ if (!pollContentType.includes("application/json")) continue;
59
+
60
+ if (!pollRes.ok) {
61
+ if (pollRes.status === 410) {
62
+ console.error(" Code expired. Run honeytree login again.");
63
+ return false;
64
+ }
65
+ continue;
66
+ }
67
+
68
+ const data = await pollRes.json();
69
+
70
+ if (data.status === "complete") {
71
+ saveAuth({
72
+ access_token: data.access_token,
73
+ user_id: data.user.id,
74
+ username: data.user.username,
75
+ });
76
+ console.log(` Linked as ${data.user.username}`);
77
+ return true;
78
+ }
79
+ } catch {
80
+ continue;
81
+ }
82
+ }
83
+ }
@@ -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
+ }