gnhf 0.1.9 → 0.1.10

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.mjs +185 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,7 @@ You wake up to a branch full of clean work and a log of everything that happened
47
47
  - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
48
48
  - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
49
49
  - **Agent-agnostic** — works with Claude Code, Codex, Rovo Dev, or OpenCode out of the box
50
+ - **Terminal-safe rendering** — the live UI keeps wide Unicode text such as emoji and CJK glyphs aligned instead of clipping or shifting the frame
50
51
 
51
52
  ## Quick Start
52
53
 
package/dist/cli.mjs CHANGED
@@ -2356,7 +2356,68 @@ function formatTokens(count) {
2356
2356
  return String(count);
2357
2357
  }
2358
2358
  //#endregion
2359
+ //#region src/utils/terminal-width.ts
2360
+ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2361
+ const MARK_REGEX = /\p{Mark}/u;
2362
+ const REGIONAL_INDICATOR_REGEX = /\p{Regional_Indicator}/u;
2363
+ const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
2364
+ function isFullWidthCodePoint(codePoint) {
2365
+ return codePoint >= 4352 && (codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 12871 && codePoint !== 12351 || codePoint >= 12880 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 42182 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65131 || codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 110592 && codePoint <= 110593 || codePoint >= 127488 && codePoint <= 127569 || codePoint >= 131072 && codePoint <= 262141);
2366
+ }
2367
+ function codePointWidth(codePoint) {
2368
+ if (codePoint === 0 || codePoint === 8204 || codePoint === 8205 || codePoint === 65038 || codePoint === 65039) return 0;
2369
+ if (MARK_REGEX.test(String.fromCodePoint(codePoint))) return 0;
2370
+ return isFullWidthCodePoint(codePoint) ? 2 : 1;
2371
+ }
2372
+ function isWideEmojiGrapheme(grapheme) {
2373
+ return grapheme.includes("‍") || grapheme.includes("️") || grapheme.includes("⃣") || REGIONAL_INDICATOR_REGEX.test(grapheme) || Array.from(grapheme).some((char) => EXTENDED_PICTOGRAPHIC_REGEX.test(char));
2374
+ }
2375
+ function splitGraphemes(text) {
2376
+ return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
2377
+ }
2378
+ function graphemeWidth(grapheme) {
2379
+ if (!grapheme) return 0;
2380
+ if (isWideEmojiGrapheme(grapheme)) return 2;
2381
+ let width = 0;
2382
+ for (const char of grapheme) width += codePointWidth(char.codePointAt(0) ?? 0);
2383
+ return width;
2384
+ }
2385
+ function stringWidth(text) {
2386
+ let width = 0;
2387
+ for (const grapheme of splitGraphemes(text)) width += graphemeWidth(grapheme);
2388
+ return width;
2389
+ }
2390
+ //#endregion
2359
2391
  //#region src/utils/wordwrap.ts
2392
+ function sliceToWidth(text, width) {
2393
+ let result = "";
2394
+ let currentWidth = 0;
2395
+ for (const grapheme of splitGraphemes(text)) {
2396
+ const nextWidth = currentWidth + graphemeWidth(grapheme);
2397
+ if (nextWidth > width) break;
2398
+ result += grapheme;
2399
+ currentWidth = nextWidth;
2400
+ }
2401
+ return result;
2402
+ }
2403
+ function splitByWidth(text, width) {
2404
+ const lines = [];
2405
+ let current = "";
2406
+ let currentWidth = 0;
2407
+ for (const grapheme of splitGraphemes(text)) {
2408
+ const glyphWidth = graphemeWidth(grapheme);
2409
+ if (current && currentWidth + glyphWidth > width) {
2410
+ lines.push(current);
2411
+ current = grapheme;
2412
+ currentWidth = glyphWidth;
2413
+ continue;
2414
+ }
2415
+ current += grapheme;
2416
+ currentWidth += glyphWidth;
2417
+ }
2418
+ if (current) lines.push(current);
2419
+ return lines;
2420
+ }
2360
2421
  function wordWrap(text, width, maxLines) {
2361
2422
  if (!text) return [];
2362
2423
  const lines = [];
@@ -2367,26 +2428,34 @@ function wordWrap(text, width, maxLines) {
2367
2428
  continue;
2368
2429
  }
2369
2430
  let current = "";
2431
+ let currentWidth = 0;
2370
2432
  for (const word of words) {
2371
- if (word.length > width) {
2433
+ const wordWidth = stringWidth(word);
2434
+ if (wordWidth > width) {
2372
2435
  if (current) {
2373
2436
  lines.push(current);
2374
2437
  current = "";
2438
+ currentWidth = 0;
2375
2439
  }
2376
- for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
2440
+ for (const slice of splitByWidth(word, width)) lines.push(slice);
2377
2441
  continue;
2378
2442
  }
2379
- if (current && current.length + 1 + word.length > width) {
2443
+ const nextWidth = current ? currentWidth + 1 + wordWidth : wordWidth;
2444
+ if (current && nextWidth > width) {
2380
2445
  lines.push(current);
2381
2446
  current = word;
2382
- } else current = current ? current + " " + word : word;
2447
+ currentWidth = wordWidth;
2448
+ } else {
2449
+ current = current ? current + " " + word : word;
2450
+ currentWidth = nextWidth;
2451
+ }
2383
2452
  }
2384
2453
  if (current) lines.push(current);
2385
2454
  }
2386
2455
  if (maxLines && lines.length > maxLines) {
2387
2456
  const capped = lines.slice(0, maxLines);
2388
2457
  const last = capped[maxLines - 1];
2389
- capped[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + "…" : last + "…";
2458
+ capped[maxLines - 1] = stringWidth(last) >= width ? sliceToWidth(last, width - 1) + "…" : last + "…";
2390
2459
  return capped;
2391
2460
  }
2392
2461
  return lines;
@@ -2402,13 +2471,13 @@ function makeCell(char, style) {
2402
2471
  return {
2403
2472
  char,
2404
2473
  style,
2405
- width: (char.codePointAt(0) ?? 0) > 65535 ? 2 : 1
2474
+ width: graphemeWidth(char)
2406
2475
  };
2407
2476
  }
2408
2477
  function textToCells(text, style) {
2409
2478
  const cells = [];
2410
- for (const char of text) {
2411
- const cell = makeCell(char, style);
2479
+ for (const grapheme of splitGraphemes(text)) {
2480
+ const cell = makeCell(grapheme, style);
2412
2481
  cells.push(cell);
2413
2482
  if (cell.width === 2) cells.push({
2414
2483
  char: "",
@@ -2490,7 +2559,7 @@ const TICK_MS = 200;
2490
2559
  const MOONS_PER_ROW = 30;
2491
2560
  const MOON_PHASE_PERIOD = 1600;
2492
2561
  const MAX_MSG_LINES = 3;
2493
- const MAX_MSG_LINE_LEN = 64;
2562
+ const MAX_MSG_LINE_LEN = CONTENT_WIDTH;
2494
2563
  const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
2495
2564
  function spacedLabel(text) {
2496
2565
  return text.split("").join(" ");
@@ -2584,52 +2653,124 @@ function renderSideStarsCells(stars, rowIndex, xOffset, sideWidth, now) {
2584
2653
  placeStarsInCells(cells, stars, rowIndex, xOffset, xOffset + sideWidth, xOffset, now);
2585
2654
  return cells;
2586
2655
  }
2656
+ function clampCellsToWidth(content, width) {
2657
+ if (content.length <= width) return content;
2658
+ const clamped = [];
2659
+ let remaining = width;
2660
+ for (let i = 0; i < content.length && remaining > 0; i++) {
2661
+ const cell = content[i];
2662
+ if (cell.width === 0) continue;
2663
+ if (cell.width > remaining) break;
2664
+ clamped.push(cell);
2665
+ remaining -= cell.width;
2666
+ if (cell.width === 2 && content[i + 1]?.width === 0) {
2667
+ clamped.push(content[i + 1]);
2668
+ i += 1;
2669
+ }
2670
+ }
2671
+ return clamped;
2672
+ }
2587
2673
  function centerLineCells(content, width) {
2588
- const w = content.length;
2674
+ const clamped = clampCellsToWidth(content, width);
2675
+ const w = clamped.length;
2589
2676
  const pad = Math.max(0, Math.floor((width - w) / 2));
2590
2677
  const rightPad = Math.max(0, width - w - pad);
2591
2678
  return [
2592
2679
  ...emptyCells(pad),
2593
- ...content,
2680
+ ...clamped,
2594
2681
  ...emptyCells(rightPad)
2595
2682
  ];
2596
2683
  }
2597
2684
  function renderResumeHintCells(width) {
2598
2685
  return centerLineCells(textToCells(RESUME_HINT, "dim"), width);
2599
2686
  }
2600
- function fitContentRows(contentRows, maxRows) {
2601
- if (contentRows.length <= maxRows) return contentRows;
2602
- const fitted = [...contentRows];
2603
- while (fitted.length > maxRows) {
2604
- const emptyRowIndex = fitted.findIndex((row) => row.length === 0);
2605
- if (emptyRowIndex === -1) break;
2606
- fitted.splice(emptyRowIndex, 1);
2607
- }
2608
- return fitted.length > maxRows ? fitted.slice(fitted.length - maxRows) : fitted;
2609
- }
2610
- function buildContentCells(prompt, agentName, state, elapsed, now) {
2611
- const rows = [];
2687
+ /**
2688
+ * Builds the centered content viewport for the renderer.
2689
+ *
2690
+ * When `availableHeight` is constrained, the layout drops optional sections in
2691
+ * priority order (ASCII art, eyebrow, agent message, then prompt) so the stats
2692
+ * row remains visible and any remaining space is used for the newest moon rows.
2693
+ */
2694
+ function buildContentCells(prompt, agentName, state, elapsed, now, availableHeight) {
2612
2695
  const isRunning = state.status === "running" || state.status === "waiting";
2613
- rows.push([]);
2614
- rows.push(...renderTitleCells(agentName));
2615
- rows.push([], []);
2696
+ const moonRows = renderMoonStripCells(state.iterations, isRunning, now);
2697
+ const maxRows = availableHeight ?? Infinity;
2698
+ if (maxRows <= 0) return [];
2699
+ const titleCells = renderTitleCells(agentName);
2700
+ const titleSpacer = titleCells[1] ?? [];
2616
2701
  const promptLines = wordWrap(prompt, CONTENT_WIDTH, MAX_PROMPT_LINES);
2702
+ const promptRows = [];
2617
2703
  for (let i = 0; i < MAX_PROMPT_LINES; i++) {
2618
2704
  const pl = promptLines[i] ?? "";
2619
- rows.push(pl ? textToCells(pl, "dim") : []);
2620
- }
2621
- rows.push([], []);
2622
- rows.push(renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount));
2623
- rows.push([], []);
2624
- rows.push(...renderAgentMessageCells(state.lastMessage, state.status));
2625
- rows.push([], []);
2626
- rows.push(...renderMoonStripCells(state.iterations, isRunning, now));
2705
+ promptRows.push(pl ? textToCells(pl, "dim") : []);
2706
+ }
2707
+ const sections = {
2708
+ top: [[]],
2709
+ eyebrow: [
2710
+ titleCells[0],
2711
+ [],
2712
+ []
2713
+ ],
2714
+ art: titleCells.slice(2),
2715
+ prompt: [
2716
+ titleSpacer,
2717
+ ...promptRows,
2718
+ [],
2719
+ []
2720
+ ],
2721
+ stats: [renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount)],
2722
+ agent: [
2723
+ [],
2724
+ [],
2725
+ ...renderAgentMessageCells(state.lastMessage, state.status)
2726
+ ],
2727
+ moon: [
2728
+ [],
2729
+ [],
2730
+ ...moonRows
2731
+ ]
2732
+ };
2733
+ const flattenSections = () => [
2734
+ ...sections.top,
2735
+ ...sections.eyebrow,
2736
+ ...sections.art,
2737
+ ...sections.prompt,
2738
+ ...sections.stats,
2739
+ ...sections.agent,
2740
+ ...sections.moon
2741
+ ];
2742
+ const optionalSections = [
2743
+ "art",
2744
+ "eyebrow",
2745
+ "agent",
2746
+ "prompt"
2747
+ ];
2748
+ let rows = flattenSections();
2749
+ for (const section of optionalSections) {
2750
+ if (rows.length <= maxRows) break;
2751
+ sections[section] = [];
2752
+ rows = flattenSections();
2753
+ }
2754
+ if (rows.length > maxRows) rows = rows.filter((row) => row.length > 0);
2755
+ if (rows.length > maxRows) {
2756
+ const nonMoonRows = [
2757
+ ...sections.top,
2758
+ ...sections.eyebrow,
2759
+ ...sections.art,
2760
+ ...sections.prompt,
2761
+ ...sections.stats,
2762
+ ...sections.agent
2763
+ ].filter((row) => row.length > 0);
2764
+ const allowedMoonRows = Math.max(0, maxRows - nonMoonRows.length);
2765
+ const visibleMoonRows = allowedMoonRows === 0 ? [] : moonRows.filter((row) => row.length > 0).slice(-allowedMoonRows);
2766
+ rows = [...nonMoonRows, ...visibleMoonRows];
2767
+ }
2627
2768
  return rows;
2628
2769
  }
2629
2770
  function buildFrameCells(prompt, agentName, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
2630
2771
  const elapsed = formatElapsed(now - state.startTime.getTime());
2631
2772
  const availableHeight = Math.max(0, terminalHeight - 2);
2632
- const contentRows = fitContentRows(buildContentCells(prompt, agentName, state, elapsed, now), availableHeight);
2773
+ const contentRows = buildContentCells(prompt, agentName, state, elapsed, now, availableHeight);
2633
2774
  while (contentRows.length < Math.min(BASE_CONTENT_ROWS, availableHeight)) contentRows.push([]);
2634
2775
  const contentCount = contentRows.length;
2635
2776
  const remaining = Math.max(0, availableHeight - contentCount);
@@ -2668,11 +2809,17 @@ var Renderer = class {
2668
2809
  cachedHeight = 0;
2669
2810
  prevCells = [];
2670
2811
  isFirstFrame = true;
2812
+ seedTop;
2813
+ seedBottom;
2814
+ seedSide;
2671
2815
  constructor(orchestrator, prompt, agentName) {
2672
2816
  this.orchestrator = orchestrator;
2673
2817
  this.prompt = prompt;
2674
2818
  this.agentName = agentName;
2675
2819
  this.state = orchestrator.getState();
2820
+ this.seedTop = Math.floor(Math.random() * 2147483646) + 1;
2821
+ this.seedBottom = Math.floor(Math.random() * 2147483646) + 1;
2822
+ this.seedSide = Math.floor(Math.random() * 2147483646) + 1;
2676
2823
  this.exitPromise = new Promise((resolve) => {
2677
2824
  this.exitResolve = resolve;
2678
2825
  });
@@ -2736,9 +2883,9 @@ var Renderer = class {
2736
2883
  rest: "dim"
2737
2884
  } : star;
2738
2885
  };
2739
- this.topStars = generateStarField(w, h, STAR_DENSITY, 42).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
2740
- this.bottomStars = generateStarField(w, h, STAR_DENSITY, 137).map((s) => shrinkBig(s, s.y < proximityRows));
2741
- this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, 99);
2886
+ this.topStars = generateStarField(w, h, STAR_DENSITY, this.seedTop).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
2887
+ this.bottomStars = generateStarField(w, h, STAR_DENSITY, this.seedBottom).map((s) => shrinkBig(s, s.y < proximityRows));
2888
+ this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, this.seedSide);
2742
2889
  return true;
2743
2890
  }
2744
2891
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {