honeytree 1.1.5 → 1.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeytree",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Grow a forest in your terminal every time you use Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,8 @@
33
33
  "author": "Varun Nukala",
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
- "chalk": "^5.4.1"
36
+ "chalk": "^5.4.1",
37
+ "honeytree": "^1.1.5"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=18"
package/src/migrate.js ADDED
@@ -0,0 +1,47 @@
1
+ import { getVirtualWidth } from "./plant.js";
2
+
3
+ function hash(seed) {
4
+ let value = seed >>> 0;
5
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
6
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
7
+ return ((value >>> 16) ^ value) >>> 0;
8
+ }
9
+
10
+ export function migrateLayout(forest, termWidth) {
11
+ if (forest.layoutVersion >= 2) return forest;
12
+
13
+ const trees = forest.trees;
14
+ if (trees.length === 0) {
15
+ forest.layoutVersion = 2;
16
+ return forest;
17
+ }
18
+
19
+ const virtualWidth = getVirtualWidth(trees.length, termWidth);
20
+ const margin = 6;
21
+ const usable = virtualWidth - margin * 2;
22
+
23
+ // Sort by current x to preserve left-to-right order
24
+ const sorted = [...trees].sort((a, b) => a.x - b.x);
25
+
26
+ // Spread evenly across usable width with deterministic jitter
27
+ const gap = trees.length === 1 ? 0 : usable / (trees.length - 1);
28
+
29
+ for (let i = 0; i < sorted.length; i++) {
30
+ const baseX = trees.length === 1
31
+ ? Math.round(virtualWidth / 2)
32
+ : Math.round(margin + i * gap);
33
+ // Deterministic jitter: +/-2 based on tree id
34
+ const jitter = (hash(sorted[i].id * 7 + 31) % 5) - 2;
35
+ sorted[i].x = Math.max(margin, Math.min(virtualWidth - margin, baseX + jitter));
36
+ }
37
+
38
+ // Ensure order is preserved after jitter — nudge if needed
39
+ for (let i = 1; i < sorted.length; i++) {
40
+ if (sorted[i].x <= sorted[i - 1].x) {
41
+ sorted[i].x = sorted[i - 1].x + 1;
42
+ }
43
+ }
44
+
45
+ forest.layoutVersion = 2;
46
+ return forest;
47
+ }
package/src/plant.js CHANGED
@@ -1,14 +1,22 @@
1
1
  import { getSprite, TREE_TYPES } from "./sprites.js";
2
2
  import { createEmptyForest, readForest, writeForest } from "./state.js";
3
3
  import { findBadgeFile, writeBadgeSVG } from "./badge.js";
4
+ import { migrateLayout } from "./migrate.js";
4
5
 
5
- const MIN_GAP = 4;
6
+ const MIN_GAP = 6;
6
7
  const DEFAULT_WIDTH = 80;
8
+ const TREE_SPACING = 6;
9
+
10
+ export function getVirtualWidth(treeCount, termWidth) {
11
+ return Math.max(termWidth, treeCount * TREE_SPACING);
12
+ }
7
13
 
8
14
  function getPlantWidth(forest) {
9
- // Use the width saved by the viewer, fall back to default
10
- if (forest.viewerWidth && forest.viewerWidth > 40) return forest.viewerWidth;
11
- return DEFAULT_WIDTH;
15
+ const termWidth = forest.viewerWidth && forest.viewerWidth > 40
16
+ ? forest.viewerWidth
17
+ : DEFAULT_WIDTH;
18
+ const treeCount = forest.trees.length + 1;
19
+ return getVirtualWidth(treeCount, termWidth);
12
20
  }
13
21
 
14
22
  function randomItem(items) {
@@ -64,6 +72,14 @@ export async function plant() {
64
72
  const forest = readForest() ?? createEmptyForest();
65
73
  const width = getPlantWidth(forest);
66
74
 
75
+ // Migrate old layouts to use virtual width
76
+ if (!forest.layoutVersion || forest.layoutVersion < 2) {
77
+ const termWidth = forest.viewerWidth && forest.viewerWidth > 40
78
+ ? forest.viewerWidth
79
+ : DEFAULT_WIDTH;
80
+ migrateLayout(forest, termWidth);
81
+ }
82
+
67
83
  // Update streak
68
84
  const today = new Date().toISOString().slice(0, 10);
69
85
  if (forest.lastActiveDate) {
package/src/renderer.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import chalk from "chalk";
2
2
 
3
- import { getSprite, TREE_TYPES } from "./sprites.js";
3
+ import { getSprite, getGroundDetail, TREE_TYPES, GROUND_DETAIL_TYPES } from "./sprites.js";
4
+ import { getVirtualWidth } from "./plant.js";
4
5
 
5
6
  const SKY_ROWS = 4;
6
- const TREE_ROWS = 7;
7
+ const TREE_ROWS = 10;
7
8
  const GROUND_ROWS = 2;
8
9
  const SPACER_ROWS = 1;
9
10
  const STATS_ROWS = 1;
@@ -92,6 +93,8 @@ const BIOMES = [
92
93
  starDensity: 12,
93
94
  starColors: ["#3a3a3a", "#444444"],
94
95
  label: "clearing",
96
+ detailDensity: 0,
97
+ detailTypes: [],
95
98
  },
96
99
  { // 10-24: young grove
97
100
  ground: ["#22492d", "#18361f"],
@@ -99,6 +102,8 @@ const BIOMES = [
99
102
  starDensity: 9,
100
103
  starColors: ["#444444", "#5d5d5d"],
101
104
  label: "grove",
105
+ detailDensity: 18,
106
+ detailTypes: ["rock", "grass"],
102
107
  },
103
108
  { // 25-49: dense woodland
104
109
  ground: ["#1e4a28", "#163a1e"],
@@ -106,6 +111,8 @@ const BIOMES = [
106
111
  starDensity: 7,
107
112
  starColors: ["#4d4d4d", "#5d5d5d", "#6a6a55"],
108
113
  label: "woodland",
114
+ detailDensity: 12,
115
+ detailTypes: ["rock", "grass", "mushroom", "bush"],
109
116
  },
110
117
  { // 50-99: old growth
111
118
  ground: ["#1a5230", "#124020"],
@@ -113,6 +120,8 @@ const BIOMES = [
113
120
  starDensity: 6,
114
121
  starColors: ["#5d5d5d", "#6d6d5a", "#7a7a60"],
115
122
  label: "old growth",
123
+ detailDensity: 8,
124
+ detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
116
125
  },
117
126
  { // 100+: ancient forest
118
127
  ground: ["#165a32", "#0e4822"],
@@ -120,6 +129,8 @@ const BIOMES = [
120
129
  starDensity: 5,
121
130
  starColors: ["#6d6d5a", "#7a7a60", "#8a8a6a"],
122
131
  label: "ancient forest",
132
+ detailDensity: 5,
133
+ detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
123
134
  },
124
135
  ];
125
136
 
@@ -144,6 +155,11 @@ function hash(seed) {
144
155
  return ((value >>> 16) ^ value) >>> 0;
145
156
  }
146
157
 
158
+ function getTreeYOffset(treeId) {
159
+ const h = hash(treeId * 13 + 7);
160
+ return h % 2; // Returns 0 or 1 (only up, never below ground)
161
+ }
162
+
147
163
  function generateStars(width, biome, twinkle = 0) {
148
164
  const stars = [];
149
165
  for (let x = 0; x < width; x += 1) {
@@ -175,6 +191,41 @@ function compositeSprite(buffer, sprite, centerX, baseY) {
175
191
  }
176
192
  }
177
193
 
194
+ function renderGroundDetails(buffer, biome, virtualWidth, groundStart) {
195
+ if (biome.detailDensity === 0 || biome.detailTypes.length === 0) return;
196
+
197
+ const detailRow = groundStart - 1; // lowest tree row, just above ground
198
+
199
+ for (let x = 0; x < virtualWidth; x += 1) {
200
+ const h = hash(x * 53 + 9973);
201
+ if (h % biome.detailDensity !== 0) continue;
202
+
203
+ // Pick detail type deterministically
204
+ const detailType = biome.detailTypes[h % biome.detailTypes.length];
205
+ const sprite = getGroundDetail(detailType);
206
+
207
+ // Only place if all cells are currently empty (no tree pixel there)
208
+ // compositeSprite centers the sprite, so match that logic here
209
+ const offsetX = x - Math.floor(sprite.width / 2);
210
+ let blocked = false;
211
+ for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex++) {
212
+ const targetY = detailRow - rowIndex;
213
+ if (targetY < 0 || targetY >= buffer.length) { blocked = true; break; }
214
+ for (let colIndex = 0; colIndex < sprite.rows[rowIndex].length; colIndex++) {
215
+ const targetX = offsetX + colIndex;
216
+ if (targetX < 0 || targetX >= virtualWidth) { blocked = true; break; }
217
+ const [, color] = sprite.rows[rowIndex][colIndex];
218
+ if (color && buffer[targetY][targetX].color) { blocked = true; break; }
219
+ }
220
+ if (blocked) break;
221
+ }
222
+ if (blocked) continue;
223
+
224
+ // Place the detail sprite (compositeSprite centers at x)
225
+ compositeSprite(buffer, sprite, x, detailRow);
226
+ }
227
+ }
228
+
178
229
  function getNextMilestone(treeCount) {
179
230
  return MILESTONES.find((value) => treeCount < value) ?? treeCount + 100;
180
231
  }
@@ -198,7 +249,7 @@ function buildStreakSegment(forest) {
198
249
  return chalk.hex(STREAK_COLOR)(`${streak}-day streak`);
199
250
  }
200
251
 
201
- function buildStatsLine(forest, biome) {
252
+ function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidth = 80) {
202
253
  const treeCount = forest.trees.length;
203
254
  const milestone = getNextMilestone(treeCount);
204
255
  const progress = milestone === 0 ? 0 : treeCount / milestone;
@@ -208,6 +259,25 @@ function buildStatsLine(forest, biome) {
208
259
  chalk.hex(BAR_FILL)("█".repeat(filledWidth)) +
209
260
  chalk.hex(BAR_EMPTY)("░".repeat(barWidth - filledWidth));
210
261
 
262
+ // Viewport minimap — only show when forest is wider than terminal
263
+ let minimap = "";
264
+ if (virtualWidth > termWidth) {
265
+ const mapWidth = 12;
266
+ const viewFraction = termWidth / virtualWidth;
267
+ const thumbWidth = Math.max(1, Math.round(viewFraction * mapWidth));
268
+ const maxOffset = virtualWidth - termWidth;
269
+ const thumbPos = maxOffset > 0
270
+ ? Math.round((viewportX / maxOffset) * (mapWidth - thumbWidth))
271
+ : 0;
272
+ const mapBar =
273
+ "─".repeat(thumbPos) +
274
+ "═".repeat(thumbWidth) +
275
+ "─".repeat(mapWidth - thumbPos - thumbWidth);
276
+ minimap = chalk.hex(STATS_TEXT)(" [") +
277
+ chalk.hex(BAR_FILL)(mapBar) +
278
+ chalk.hex(STATS_TEXT)("]");
279
+ }
280
+
211
281
  return (
212
282
  chalk.hex(STATS_ACCENT)(" honeytree") +
213
283
  chalk.hex(STATS_TEXT)(
@@ -217,23 +287,32 @@ function buildStatsLine(forest, biome) {
217
287
  chalk.hex(STATS_TEXT)(" · ") +
218
288
  bar +
219
289
  chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
220
- chalk.hex("#555555")(` [${biome.label}]`)
290
+ chalk.hex("#555555")(` [${biome.label}]`) +
291
+ minimap
221
292
  );
222
293
  }
223
294
 
224
295
  export function renderFrame(forest, termWidth = 80, options = {}) {
225
296
  const width = Math.max(40, termWidth);
226
- const buffer = createBuffer(width);
297
+ const treeCount = forest.trees.length;
298
+ const virtualWidth = options.virtualWidth ?? getVirtualWidth(treeCount, width);
299
+ const viewportX = Math.max(
300
+ 0,
301
+ Math.min(options.viewportX ?? 0, Math.max(0, virtualWidth - width)),
302
+ );
303
+
304
+ // Build the full virtual-width buffer
305
+ const buffer = createBuffer(virtualWidth);
227
306
  const groundStart = SKY_ROWS + TREE_ROWS;
228
- const biome = getBiome(forest.trees.length);
307
+ const biome = getBiome(treeCount);
229
308
  const wilt = getWiltFactor(forest.lastActiveDate);
230
309
 
231
- for (const star of generateStars(width, biome, options.twinkleSeed ?? 0)) {
310
+ for (const star of generateStars(virtualWidth, biome, options.twinkleSeed ?? 0)) {
232
311
  buffer[star.y][star.x] = { char: star.char, color: star.color };
233
312
  }
234
313
 
235
314
  for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
236
- for (let x = 0; x < width; x += 1) {
315
+ for (let x = 0; x < virtualWidth; x += 1) {
237
316
  buffer[groundStart + rowIndex][x] = {
238
317
  char: "█",
239
318
  color: biome.ground[rowIndex],
@@ -243,19 +322,23 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
243
322
 
244
323
  const treeBaseY = groundStart - 1;
245
324
  for (const tree of forest.trees) {
246
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
325
+ const yOffset = getTreeYOffset(tree.id);
326
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
247
327
  }
248
328
 
249
- applyFog(buffer, wilt, width);
329
+ renderGroundDetails(buffer, biome, virtualWidth, groundStart);
330
+
331
+ applyFog(buffer, wilt, virtualWidth);
250
332
 
333
+ // Slice the viewport from the virtual buffer
251
334
  const lines = [];
252
335
  for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
253
336
  let line = "";
254
- for (const cell of buffer[y]) {
337
+ for (let x = viewportX; x < viewportX + width; x += 1) {
338
+ const cell = buffer[y][x];
255
339
  if (!cell.color) {
256
340
  line += cell.char;
257
341
  } else {
258
- // Apply wilting to tree rows and ground (skip sky)
259
342
  const color = wilt > 0 && y >= SKY_ROWS ? wiltColor(cell.color, wilt) : cell.color;
260
343
  line += chalk.hex(color)(cell.char);
261
344
  }
@@ -264,9 +347,9 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
264
347
  }
265
348
 
266
349
  lines.push("");
267
- lines.push(buildStatsLine(forest, biome));
350
+ lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
268
351
  lines.push(
269
- chalk.hex("#555555")(" add your forest to your README → ") +
352
+ chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
270
353
  chalk.hex(STATS_ACCENT)("honeytree badge"),
271
354
  );
272
355
 
@@ -297,9 +380,12 @@ export function buildScene(forest, width) {
297
380
 
298
381
  const treeBaseY = groundStart - 1;
299
382
  for (const tree of forest.trees) {
300
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
383
+ const yOffset = getTreeYOffset(tree.id);
384
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
301
385
  }
302
386
 
387
+ renderGroundDetails(buffer, biome, w, groundStart);
388
+
303
389
  applyFog(buffer, wilt, w);
304
390
 
305
391
  if (wilt > 0) {
@@ -333,7 +419,8 @@ export function renderPlainText(forest, width = 60) {
333
419
 
334
420
  const treeBaseY = groundStart - 1;
335
421
  for (const tree of forest.trees) {
336
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
422
+ const yOffset = getTreeYOffset(tree.id);
423
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
337
424
  }
338
425
 
339
426
  const lines = [];
package/src/sprites.js CHANGED
@@ -14,6 +14,19 @@ const COLORS = {
14
14
  cherryBloom: "#f0b7cf",
15
15
  };
16
16
 
17
+ const DETAIL_COLORS = {
18
+ mushroom: "#c4a882",
19
+ mushroomCap: "#9e4a3a",
20
+ rock: "#6b6b6b",
21
+ rockLight: "#8a8a8a",
22
+ grass: "#4a7a3a",
23
+ grassLight: "#6ba85a",
24
+ leaf: "#8a6a3a",
25
+ leafDark: "#6a4a2a",
26
+ bush: "#3a6a2a",
27
+ bushLight: "#5a8a4a",
28
+ };
29
+
17
30
  function parse(template, palette) {
18
31
  const lines = template.trim().split("\n");
19
32
  const width = Math.max(...lines.map((line) => line.length));
@@ -229,6 +242,50 @@ pPPpPPPp
229
242
  },
230
243
  };
231
244
 
245
+ const GROUND_DETAILS = {
246
+ mushroom: parse(
247
+ `
248
+ rr
249
+ t
250
+ `,
251
+ { r: DETAIL_COLORS.mushroomCap, t: DETAIL_COLORS.mushroom },
252
+ ),
253
+ rock: parse(
254
+ `
255
+ rR
256
+ `,
257
+ { r: DETAIL_COLORS.rock, R: DETAIL_COLORS.rockLight },
258
+ ),
259
+ grass: parse(
260
+ `
261
+ gG
262
+ `,
263
+ { g: DETAIL_COLORS.grass, G: DETAIL_COLORS.grassLight },
264
+ ),
265
+ leaf: parse(
266
+ `
267
+ lL
268
+ `,
269
+ { l: DETAIL_COLORS.leaf, L: DETAIL_COLORS.leafDark },
270
+ ),
271
+ bush: parse(
272
+ `
273
+ bB
274
+ `,
275
+ { b: DETAIL_COLORS.bush, B: DETAIL_COLORS.bushLight },
276
+ ),
277
+ };
278
+
279
+ export const GROUND_DETAIL_TYPES = Object.keys(GROUND_DETAILS);
280
+
281
+ export function getGroundDetail(type) {
282
+ const detail = GROUND_DETAILS[type];
283
+ if (!detail) {
284
+ throw new Error(`Unknown ground detail type: ${type}`);
285
+ }
286
+ return detail;
287
+ }
288
+
232
289
  function getGrowthStage(growth) {
233
290
  if (growth < 0.2) return "seed";
234
291
  if (growth < 0.5) return "sapling";
package/src/viewer.js CHANGED
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
 
3
3
  import { renderFrame } from "./renderer.js";
4
4
  import { getForestFile, readForest, writeForest } from "./state.js";
5
+ import { migrateLayout } from "./migrate.js";
6
+ import { getVirtualWidth } from "./plant.js";
5
7
 
6
8
  function writeAnsi(code) {
7
9
  process.stdout.write(code);
@@ -23,37 +25,10 @@ function moveHome() {
23
25
  writeAnsi("\x1b[H");
24
26
  }
25
27
 
26
- function renderForest(forest, twinkleSeed = 0) {
27
- moveHome();
28
- process.stdout.write(renderFrame(forest, process.stdout.columns || 80, { twinkleSeed }));
29
- }
30
-
31
28
  function delay(ms) {
32
29
  return new Promise((resolve) => setTimeout(resolve, ms));
33
30
  }
34
31
 
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
32
  export async function viewer() {
58
33
  const forestFile = getForestFile();
59
34
  let forest = readForest();
@@ -63,6 +38,13 @@ export async function viewer() {
63
38
  process.exit(1);
64
39
  }
65
40
 
41
+ // Migrate old layouts on first view
42
+ if (forest && (!forest.layoutVersion || forest.layoutVersion < 2)) {
43
+ const termWidth = process.stdout.columns || 80;
44
+ migrateLayout(forest, termWidth);
45
+ // Will be written to disk by syncWidth below
46
+ }
47
+
66
48
  // Save terminal width so plant knows how wide to spread trees
67
49
  let ignoreNextChange = false;
68
50
  function syncWidth() {
@@ -74,16 +56,65 @@ export async function viewer() {
74
56
  }
75
57
  }
76
58
 
59
+ let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
60
+ let lastTotalPrompts = forest.totalPrompts;
61
+ let animating = false;
62
+
63
+ let viewportX = forest.viewportX || 0;
64
+ const PAN_STEP = 4;
65
+
66
+ function getViewportWidth() {
67
+ return process.stdout.columns || 80;
68
+ }
69
+
70
+ function clampViewport(x) {
71
+ const vw = getVirtualWidth(forest.trees.length, getViewportWidth());
72
+ return Math.max(0, Math.min(x, Math.max(0, vw - getViewportWidth())));
73
+ }
74
+
75
+ function renderForest(forest, twinkleSeed = 0) {
76
+ clearScreen();
77
+ const termWidth = process.stdout.columns || 80;
78
+ const vw = getVirtualWidth(forest.trees.length, termWidth);
79
+ process.stdout.write(renderFrame(forest, termWidth, {
80
+ twinkleSeed,
81
+ viewportX,
82
+ virtualWidth: vw,
83
+ }));
84
+ }
85
+
86
+ async function animateNewTree(forest, newTreeId) {
87
+ const tree = forest.trees.find((entry) => entry.id === newTreeId);
88
+ if (!tree) {
89
+ renderForest(forest);
90
+ return;
91
+ }
92
+
93
+ const originalGrowth = tree.growth;
94
+ const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
95
+ (value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
96
+ );
97
+
98
+ for (let index = 0; index < frames.length; index += 1) {
99
+ tree.growth = frames[index];
100
+ renderForest(forest, index);
101
+ await delay(120);
102
+ }
103
+
104
+ tree.growth = originalGrowth;
105
+ renderForest(forest);
106
+ }
107
+
77
108
  syncWidth();
78
109
  hideCursor();
79
110
  clearScreen();
80
111
  renderForest(forest);
81
112
 
82
- let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
83
- let lastTotalPrompts = forest.totalPrompts;
84
- let animating = false;
85
-
86
113
  const cleanup = () => {
114
+ // Persist viewport position for next session
115
+ forest.viewportX = viewportX;
116
+ ignoreNextChange = true;
117
+ writeForest(forest);
87
118
  showCursor();
88
119
  clearScreen();
89
120
  console.log(
@@ -100,6 +131,34 @@ export async function viewer() {
100
131
  renderForest(forest);
101
132
  });
102
133
 
134
+ // Enable raw mode for keypress handling
135
+ if (process.stdin.isTTY) {
136
+ process.stdin.setRawMode(true);
137
+ process.stdin.resume();
138
+ process.stdin.on("data", (data) => {
139
+ const key = data.toString();
140
+ // Ctrl+C or q to quit
141
+ if (key === "\x03" || key === "q") {
142
+ cleanup();
143
+ return;
144
+ }
145
+ // Left arrow: \x1b[D
146
+ if (key === "\x1b[D") {
147
+ viewportX = clampViewport(viewportX - PAN_STEP);
148
+ forest.viewportX = viewportX;
149
+ renderForest(forest);
150
+ return;
151
+ }
152
+ // Right arrow: \x1b[C
153
+ if (key === "\x1b[C") {
154
+ viewportX = clampViewport(viewportX + PAN_STEP);
155
+ forest.viewportX = viewportX;
156
+ renderForest(forest);
157
+ return;
158
+ }
159
+ });
160
+ }
161
+
103
162
  // Check for changes — used by both fs.watch and polling fallback
104
163
  async function checkForUpdates() {
105
164
  if (animating) return;
@@ -121,6 +180,12 @@ export async function viewer() {
121
180
 
122
181
  if (nextMaxId > lastMaxId) {
123
182
  lastMaxId = nextMaxId;
183
+ // Center viewport on the new tree
184
+ const newTree = forest.trees.find((t) => t.id === nextMaxId);
185
+ if (newTree) {
186
+ const termWidth = getViewportWidth();
187
+ viewportX = clampViewport(newTree.x - Math.floor(termWidth / 2));
188
+ }
124
189
  animating = true;
125
190
  await animateNewTree(forest, nextMaxId);
126
191
  animating = false;