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,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.");
@@ -0,0 +1,129 @@
1
+ import { execFile } from "node:child_process";
2
+ import { isLoggedIn, loginWithDevice, getAuth } from "./auth.js";
3
+ import { readForest } from "./state.js";
4
+ import { syncToCloud } from "./sync.js";
5
+ import { asciiTree } from "./ascii-tree.js";
6
+
7
+ const API_URL = process.env.HONEYTREE_API_URL || "https://tryhoney.xyz";
8
+ const POLL_INTERVAL_MS = 4000;
9
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000;
10
+
11
+ // ---- Pure helpers (unit-tested) ----
12
+
13
+ export function formatAvailability({ available, virtualTrees, virtualToNext }) {
14
+ const lines = [];
15
+ lines.push("");
16
+ lines.push(" 50 virtual trees = 1 real tree.");
17
+ if (available >= 1) {
18
+ lines.push(` You have ${available} real tree${available > 1 ? "s" : ""} ready to plant.`);
19
+ } else {
20
+ lines.push(" No real trees ready to plant yet.");
21
+ }
22
+ lines.push(` ${virtualToNext} virtual trees until your next one. (${virtualTrees} planted so far.)`);
23
+ lines.push("");
24
+ return lines;
25
+ }
26
+
27
+ export function findNewCompletedPlantings(baselineIds, currentPlantings) {
28
+ const seen = new Set(baselineIds);
29
+ return (currentPlantings || []).filter((p) => p.status === "completed" && !seen.has(p.id));
30
+ }
31
+
32
+ export function findNewBadgeLabels(baselineSlugs, currentBadges) {
33
+ const seen = new Set(baselineSlugs);
34
+ return (currentBadges || [])
35
+ .filter((b) => b.unlocked && !seen.has(b.slug))
36
+ .map((b) => b.label);
37
+ }
38
+
39
+ // ---- I/O glue ----
40
+
41
+ function openBrowser(url) {
42
+ // Use execFile (no shell) so the URL is passed as a literal argument, never
43
+ // interpolated into a shell command. On Windows, `start` is a cmd builtin.
44
+ const [cmd, args] =
45
+ process.platform === "darwin" ? ["open", [url]] :
46
+ process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] :
47
+ ["xdg-open", [url]];
48
+ execFile(cmd, args, (err) => {
49
+ if (err) console.warn(` (Could not open the browser automatically — visit ${url})`);
50
+ });
51
+ }
52
+
53
+ async function fetchStats() {
54
+ const auth = getAuth();
55
+ if (!auth?.access_token) return null;
56
+ try {
57
+ const res = await fetch(`${API_URL}/api/user/stats`, {
58
+ headers: { Authorization: `Bearer ${auth.access_token}` },
59
+ });
60
+ if (!res.ok) return null;
61
+ return await res.json();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export async function plant() {
68
+ if (!isLoggedIn()) {
69
+ console.log(" Linking your terminal first...");
70
+ const ok = await loginWithDevice();
71
+ if (!ok) return;
72
+ }
73
+
74
+ // Make sure the server has the latest virtual count.
75
+ const forest = readForest();
76
+ if (forest) await syncToCloud(forest);
77
+
78
+ const stats = await fetchStats();
79
+ if (!stats) {
80
+ console.log(" Could not reach Honeytree. Try again in a moment.");
81
+ return;
82
+ }
83
+
84
+ for (const line of formatAvailability({
85
+ available: stats.available_to_plant ?? 0,
86
+ virtualTrees: stats.virtual_trees ?? 0,
87
+ virtualToNext: stats.virtual_to_next ?? 50,
88
+ })) {
89
+ console.log(line);
90
+ }
91
+
92
+ if ((stats.available_to_plant ?? 0) < 1) {
93
+ return;
94
+ }
95
+
96
+ const baselineIds = (stats.plantings || []).map((p) => p.id);
97
+ const baselineBadgeSlugs = (stats.badges || []).filter((b) => b.unlocked).map((b) => b.slug);
98
+
99
+ const url = `${API_URL}/dashboard?plant=1`;
100
+ console.log(` Opening ${url}`);
101
+ console.log(" Complete your payment in the browser — I'll wait here.");
102
+ openBrowser(url);
103
+
104
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
105
+ while (Date.now() < deadline) {
106
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
107
+ const latest = await fetchStats();
108
+ if (!latest) continue;
109
+ const newlyPlanted = findNewCompletedPlantings(baselineIds, latest.plantings);
110
+ if (newlyPlanted.length === 0) continue;
111
+
112
+ const treesPlanted = newlyPlanted.reduce((s, p) => s + (p.real_trees_planted || 0), 0);
113
+ const newBadges = findNewBadgeLabels(baselineBadgeSlugs, latest.badges);
114
+ const hasBloomer = (latest.badges || []).some((b) => b.slug === "cherry" && b.unlocked);
115
+
116
+ console.log("");
117
+ console.log(asciiTree(hasBloomer));
118
+ console.log(` 🌳 Planted ${treesPlanted} real tree${treesPlanted > 1 ? "s" : ""}!`);
119
+ console.log(` You've now planted ${latest.real_trees_planted} real tree${latest.real_trees_planted > 1 ? "s" : ""} total.`);
120
+ for (const label of newBadges) {
121
+ console.log(` 🏅 ${label} unlocked!`);
122
+ }
123
+ console.log(" A receipt is on its way to your email.");
124
+ console.log("");
125
+ return;
126
+ }
127
+
128
+ console.log(" Still processing — check your dashboard, or run `honeytree plant` again.");
129
+ }
package/src/plant.js CHANGED
@@ -1,7 +1,14 @@
1
- import { getSprite, TREE_TYPES } from "./sprites.js";
1
+ import { getSprite } from "./sprites.js";
2
+ import { getUnlockedVarietyKeys } from "./rewards.js";
3
+ import { unlockedPool, pickSpecies } from "./varieties.js";
2
4
  import { createEmptyForest, readForest, writeForest } from "./state.js";
3
5
  import { findBadgeFile, writeBadgeSVG } from "./badge.js";
4
6
  import { migrateLayout } from "./migrate.js";
7
+ import { isLoggedIn } from "./auth.js";
8
+ import { syncToCloud } from "./sync.js";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
5
12
 
6
13
  const MIN_GAP = 6;
7
14
  const DEFAULT_WIDTH = 80;
@@ -11,7 +18,7 @@ export function getVirtualWidth(treeCount, termWidth) {
11
18
  return Math.max(termWidth, treeCount * TREE_SPACING);
12
19
  }
13
20
 
14
- function getPlantWidth(forest) {
21
+ export function getPlantWidth(forest) {
15
22
  const termWidth = forest.viewerWidth && forest.viewerWidth > 40
16
23
  ? forest.viewerWidth
17
24
  : DEFAULT_WIDTH;
@@ -19,10 +26,6 @@ function getPlantWidth(forest) {
19
26
  return getVirtualWidth(treeCount, termWidth);
20
27
  }
21
28
 
22
- function randomItem(items) {
23
- return items[Math.floor(Math.random() * items.length)];
24
- }
25
-
26
29
  function randomGrowth() {
27
30
  return Math.round((0.3 + Math.random() * 0.7) * 100) / 100;
28
31
  }
@@ -35,7 +38,7 @@ function occupiedRanges(trees) {
35
38
  });
36
39
  }
37
40
 
38
- function findOpenX(trees, type, growth, width) {
41
+ export function findOpenX(trees, type, growth, width) {
39
42
  const sprite = getSprite(type, growth);
40
43
  const half = Math.floor(sprite.width / 2);
41
44
  const margin = half + 1;
@@ -68,7 +71,7 @@ function daysBetween(dateA, dateB) {
68
71
  return Math.round(Math.abs(b - a) / (24 * 60 * 60 * 1000));
69
72
  }
70
73
 
71
- export async function plant() {
74
+ export async function tick(shape = null) {
72
75
  const forest = readForest() ?? createEmptyForest();
73
76
  const width = getPlantWidth(forest);
74
77
 
@@ -101,17 +104,21 @@ export async function plant() {
101
104
  tree.growth = nudgeGrowth(tree.growth);
102
105
  }
103
106
 
104
- const type = randomItem(TREE_TYPES);
105
- const growth = randomGrowth();
107
+ let type = shape?.type;
108
+ let variant = shape?.variant ?? null;
109
+ if (!type) {
110
+ const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
111
+ type = species.type;
112
+ variant = species.variant ?? null;
113
+ }
114
+ const growth = typeof shape?.growth === "number" ? shape.growth : randomGrowth();
106
115
  const nextId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0) + 1;
116
+ const x = typeof shape?.x === "number" ? shape.x : findOpenX(forest.trees, type, growth, width);
107
117
 
108
- forest.trees.push({
109
- id: nextId,
110
- type,
111
- growth,
112
- x: findOpenX(forest.trees, type, growth, width),
113
- plantedAt: new Date().toISOString(),
114
- });
118
+ const tree = { id: nextId, type, growth, x, plantedAt: new Date().toISOString() };
119
+ if (shape?.heightBonus) tree.heightBonus = shape.heightBonus;
120
+ if (variant) tree.variant = variant;
121
+ forest.trees.push(tree);
115
122
  forest.totalPrompts += 1;
116
123
 
117
124
  writeForest(forest);
@@ -121,4 +128,19 @@ export async function plant() {
121
128
  const badgePath = findBadgeFile();
122
129
  if (badgePath) writeBadgeSVG(forest, badgePath);
123
130
  } catch {}
131
+
132
+ // Cloud sync every 10 prompts (fire-and-forget)
133
+ if (isLoggedIn() && forest.totalPrompts % 10 === 0) {
134
+ syncToCloud(forest).catch(() => {});
135
+ }
136
+
137
+ // Milestone check at every 50 prompts — unlocks 1 real tree planting
138
+ if (isLoggedIn() && forest.totalPrompts > 0 && forest.totalPrompts % 50 === 0) {
139
+ const milestoneFile = path.join(os.homedir(), ".honeydew", "milestone.json");
140
+ fs.mkdirSync(path.dirname(milestoneFile), { recursive: true });
141
+ fs.writeFileSync(
142
+ milestoneFile,
143
+ JSON.stringify({ totalPrompts: forest.totalPrompts, timestamp: Date.now() })
144
+ );
145
+ }
124
146
  }