Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [Unreleased]

### Features

* **renderer:** randomize star field seeds between runs

### Bug Fixes

* **renderer:** keep wide Unicode graphemes wrapped and aligned in the live terminal UI

## [0.1.9](https://github.com/kunchenguid/gnhf/compare/gnhf-v0.1.8...gnhf-v0.1.9) (2026-04-03)


Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ You wake up to a branch full of clean work and a log of everything that happened
- **Dead simple** β€” one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
- **Long running** β€” each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
- **Agent-agnostic** β€” works with Claude Code, Codex, Rovo Dev, or OpenCode out of the box
- **Terminal-safe rendering** β€” the live UI keeps wide Unicode text such as emoji and CJK glyphs aligned instead of clipping or shifting the frame

## Quick Start

Expand Down
11 changes: 6 additions & 5 deletions src/renderer-diff.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { graphemeWidth, splitGraphemes } from "./utils/terminal-width.js";

// ── Cell types ───────────────────────────────────────────────

export type Style = "normal" | "bold" | "dim";

export interface Cell {
char: string;
style: Style;
/** 1 = normal char, 2 = wide (emoji), 0 = continuation of wide char */
/** 1 = normal grapheme, 2 = wide grapheme, 0 = continuation of a wide grapheme */
width: number;
}

Expand All @@ -20,14 +22,13 @@ export interface Change {
const SPACE: Cell = { char: " ", style: "normal", width: 1 };

export function makeCell(char: string, style: Style): Cell {
const cp = char.codePointAt(0) ?? 0;
return { char, style, width: cp > 0xffff ? 2 : 1 };
return { char, style, width: graphemeWidth(char) };
}

export function textToCells(text: string, style: Style): Cell[] {
const cells: Cell[] = [];
for (const char of text) {
const cell = makeCell(char, style);
for (const grapheme of splitGraphemes(text)) {
const cell = makeCell(grapheme, style);
cells.push(cell);
if (cell.width === 2) {
cells.push({ char: "", style: "normal", width: 0 });
Expand Down
86 changes: 86 additions & 0 deletions src/renderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi } from "vitest";
import * as renderer from "./renderer.js";
import {
Renderer,
stripAnsi,
Expand All @@ -9,6 +10,7 @@ import {
renderMoonStrip,
renderStarFieldLines,
buildFrame,
buildFrameCells,
buildContentCells,
} from "./renderer.js";
import { rowToString } from "./renderer-diff.js";
Expand Down Expand Up @@ -82,6 +84,14 @@ describe("renderAgentMessage", () => {
expect(plain).toContain("\u2026");
expect(plain).not.toContain("Line four");
});

it("keeps a trailing wide glyph intact at the message width boundary", () => {
expect(
renderAgentMessage(`${"A".repeat(62)}πŸŒ•`, "running")
.map(stripAnsi)
.filter(Boolean),
).toEqual(["A".repeat(62), "πŸŒ•"]);
});
});

describe("renderMoonStrip", () => {
Expand Down Expand Up @@ -135,6 +145,35 @@ describe("buildFrame", () => {
const stripCursorHome = (frame: string) =>
frame.startsWith("\x1b[H") ? frame.slice(3) : frame;

it("wraps a trailing wide prompt glyph onto the next line instead of dropping it", () => {
const state: OrchestratorState = {
status: "running",
currentIteration: 1,
totalInputTokens: 0,
totalOutputTokens: 0,
commitCount: 0,
iterations: [],
successCount: 0,
failCount: 0,
consecutiveFailures: 0,
startTime: new Date("2026-01-01T00:00:00Z"),
waitingUntil: null,
lastMessage: null,
};

const lines = renderer
.buildContentLines(
`${"A".repeat(62)}πŸŒ•`,
"claude",
state,
"00:00:00",
Date.now(),
)
.map(stripAnsi);

expect(lines.slice(8, 11).filter(Boolean)).toEqual(["A".repeat(62), "πŸŒ•"]);
});

it("shows the stop and resume hint on the second-to-last row with blank bottom padding", () => {
const state: OrchestratorState = {
status: "running",
Expand Down Expand Up @@ -217,6 +256,47 @@ describe("buildFrame", () => {
expect(plainLines.at(-1)?.trim()).toBe("");
});

it("does not let wide agent text push side stars out of position", () => {
// Use width where (width - CONTENT_WIDTH) is even so sideWidth*2 + 63 = width
const terminalWidth = 83;
const terminalHeight = 30;
// Message that overflows CONTENT_WIDTH only because the trailing glyph is 2 cells wide.
const longMessage = `${"A".repeat(62)}πŸŒ•`;

const state: OrchestratorState = {
status: "running",
currentIteration: 1,
totalInputTokens: 500,
totalOutputTokens: 300,
commitCount: 0,
iterations: [],
successCount: 0,
failCount: 0,
consecutiveFailures: 0,
startTime: new Date("2026-01-01T00:00:00Z"),
waitingUntil: null,
lastMessage: longMessage,
};

const cells = buildFrameCells(
"ship it",
"claude",
state,
[],
[],
[],
Date.now(),
terminalWidth,
terminalHeight,
);

// Every row must be exactly terminalWidth β€” a wider agent message row
// would push the right-side stars out of alignment.
for (let r = 0; r < cells.length; r++) {
expect(cells[r]).toHaveLength(terminalWidth);
}
});

it("keeps stats visible when moon rows exceed the content viewport", () => {
const state: OrchestratorState = {
status: "done",
Expand Down Expand Up @@ -301,6 +381,12 @@ describe("buildFrame", () => {
});
});

describe("renderer module exports", () => {
it("does not expose clampCellsToWidth", () => {
expect("clampCellsToWidth" in renderer).toBe(false);
});
});

describe("buildContentCells adaptive height", () => {
const state: OrchestratorState = {
status: "running",
Expand Down
51 changes: 42 additions & 9 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const TICK_MS = 200;
const MOONS_PER_ROW = 30;
const MOON_PHASE_PERIOD = 1600;
const MAX_MSG_LINES = 3;
const MAX_MSG_LINE_LEN = 64;
const MAX_MSG_LINE_LEN = CONTENT_WIDTH;
const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";

export type RendererExitReason = "interrupted" | "stopped";
Expand Down Expand Up @@ -249,11 +249,35 @@ function renderSideStarsCells(
return cells;
}

function clampCellsToWidth(content: Cell[], width: number): Cell[] {
if (content.length <= width) return content;

const clamped: Cell[] = [];
let remaining = width;

for (let i = 0; i < content.length && remaining > 0; i++) {
const cell = content[i];
if (cell.width === 0) continue;
if (cell.width > remaining) break;

clamped.push(cell);
remaining -= cell.width;

if (cell.width === 2 && content[i + 1]?.width === 0) {
clamped.push(content[i + 1]);
i += 1;
}
}

return clamped;
}

function centerLineCells(content: Cell[], width: number): Cell[] {
const w = content.length;
const clamped = clampCellsToWidth(content, width);
const w = clamped.length;
const pad = Math.max(0, Math.floor((width - w) / 2));
const rightPad = Math.max(0, width - w - pad);
return [...emptyCells(pad), ...content, ...emptyCells(rightPad)];
return [...emptyCells(pad), ...clamped, ...emptyCells(rightPad)];
}

function renderResumeHintCells(width: number): Cell[] {
Expand Down Expand Up @@ -482,12 +506,18 @@ export class Renderer {
private cachedHeight = 0;
private prevCells: Cell[][] = [];
private isFirstFrame = true;
private seedTop: number;
private seedBottom: number;
private seedSide: number;

constructor(orchestrator: Orchestrator, prompt: string, agentName: string) {
this.orchestrator = orchestrator;
this.prompt = prompt;
this.agentName = agentName;
this.state = orchestrator.getState();
this.seedTop = Math.floor(Math.random() * 2147483646) + 1;
this.seedBottom = Math.floor(Math.random() * 2147483646) + 1;
this.seedSide = Math.floor(Math.random() * 2147483646) + 1;
this.exitPromise = new Promise((resolve) => {
this.exitResolve = resolve;
});
Expand Down Expand Up @@ -550,17 +580,20 @@ export class Renderer {
const star = s.char !== "Β·" ? { ...s, char: "Β·" } : s;
return star.rest === "bright" ? { ...star, rest: "dim" } : star;
};
this.topStars = generateStarField(w, h, STAR_DENSITY, 42).map((s) =>
shrinkBig(s, s.y >= topHeight - proximityRows),
);
this.bottomStars = generateStarField(w, h, STAR_DENSITY, 137).map((s) =>
shrinkBig(s, s.y < proximityRows),
this.topStars = generateStarField(w, h, STAR_DENSITY, this.seedTop).map(
(s) => shrinkBig(s, s.y >= topHeight - proximityRows),
);
this.bottomStars = generateStarField(
w,
h,
STAR_DENSITY,
this.seedBottom,
).map((s) => shrinkBig(s, s.y < proximityRows));
this.sideStars = generateStarField(
w,
Math.max(BASE_CONTENT_ROWS, availableHeight),
STAR_DENSITY,
99,
this.seedSide,
);
return true;
}
Expand Down
8 changes: 8 additions & 0 deletions src/utils/terminal-width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { graphemeWidth } from "./terminal-width.js";

describe("graphemeWidth", () => {
it("treats narrow supplementary-plane graphemes as single-width", () => {
expect(graphemeWidth("π’œ")).toBe(1);
});
});
77 changes: 77 additions & 0 deletions src/utils/terminal-width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const graphemeSegmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme",
});

const MARK_REGEX = /\p{Mark}/u;
const REGIONAL_INDICATOR_REGEX = /\p{Regional_Indicator}/u;
const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;

function isFullWidthCodePoint(codePoint: number): boolean {
return (
codePoint >= 0x1100 &&
(codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) ||
(codePoint >= 0x3250 && codePoint <= 0x4dbf) ||
(codePoint >= 0x4e00 && codePoint <= 0xa4c6) ||
(codePoint >= 0xa960 && codePoint <= 0xa97c) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6b) ||
(codePoint >= 0xff01 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1b000 && codePoint <= 0x1b001) ||
(codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
);
}

function codePointWidth(codePoint: number): number {
if (
codePoint === 0 ||
codePoint === 0x200c ||
codePoint === 0x200d ||
codePoint === 0xfe0e ||
codePoint === 0xfe0f
) {
return 0;
}

if (MARK_REGEX.test(String.fromCodePoint(codePoint))) return 0;
return isFullWidthCodePoint(codePoint) ? 2 : 1;
}

function isWideEmojiGrapheme(grapheme: string): boolean {
return (
grapheme.includes("\u200d") ||
grapheme.includes("\ufe0f") ||
grapheme.includes("\u20e3") ||
REGIONAL_INDICATOR_REGEX.test(grapheme) ||
Array.from(grapheme).some((char) => EXTENDED_PICTOGRAPHIC_REGEX.test(char))
);
}

export function splitGraphemes(text: string): string[] {
return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
}

export function graphemeWidth(grapheme: string): number {
if (!grapheme) return 0;
if (isWideEmojiGrapheme(grapheme)) return 2;

let width = 0;
for (const char of grapheme) {
width += codePointWidth(char.codePointAt(0) ?? 0);
}
return width;
}

export function stringWidth(text: string): number {
let width = 0;
for (const grapheme of splitGraphemes(text)) {
width += graphemeWidth(grapheme);
}
return width;
}
8 changes: 8 additions & 0 deletions src/utils/wordwrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ describe("wordWrap", () => {
expect(wordWrap("abcdefghij", 5)).toEqual(["abcde", "fghij"]);
});

it("treats BMP full-width characters as double-width", () => {
expect(wordWrap("ζΌ’A", 2)).toEqual(["ζΌ’", "A"]);
});

it("keeps ZWJ emoji graphemes intact when wrapping", () => {
expect(wordWrap("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", 2)).toEqual(["πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"]);
});

it("caps at maxLines with ellipsis on last line", () => {
const text = "one two three four five six seven eight";
const lines = wordWrap(text, 10, 2);
Expand Down
Loading
Loading