diff --git a/CHANGELOG.md b/CHANGELOG.md index d62e3a0..d000ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 8f3de35..d18c3f3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/renderer-diff.ts b/src/renderer-diff.ts index 22e67ee..60ead9d 100644 --- a/src/renderer-diff.ts +++ b/src/renderer-diff.ts @@ -1,3 +1,5 @@ +import { graphemeWidth, splitGraphemes } from "./utils/terminal-width.js"; + // ── Cell types ─────────────────────────────────────────────── export type Style = "normal" | "bold" | "dim"; @@ -5,7 +7,7 @@ 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; } @@ -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 }); diff --git a/src/renderer.test.ts b/src/renderer.test.ts index dc9e6d8..ffc4386 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -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, @@ -9,6 +10,7 @@ import { renderMoonStrip, renderStarFieldLines, buildFrame, + buildFrameCells, buildContentCells, } from "./renderer.js"; import { rowToString } from "./renderer-diff.js"; @@ -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", () => { @@ -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", @@ -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", @@ -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", diff --git a/src/renderer.ts b/src/renderer.ts index 67c5e7d..15514f8 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -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"; @@ -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[] { @@ -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; }); @@ -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; } diff --git a/src/utils/terminal-width.test.ts b/src/utils/terminal-width.test.ts new file mode 100644 index 0000000..434e2ac --- /dev/null +++ b/src/utils/terminal-width.test.ts @@ -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); + }); +}); diff --git a/src/utils/terminal-width.ts b/src/utils/terminal-width.ts new file mode 100644 index 0000000..eb4e557 --- /dev/null +++ b/src/utils/terminal-width.ts @@ -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; +} diff --git a/src/utils/wordwrap.test.ts b/src/utils/wordwrap.test.ts index cf96440..f413ce5 100644 --- a/src/utils/wordwrap.test.ts +++ b/src/utils/wordwrap.test.ts @@ -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); diff --git a/src/utils/wordwrap.ts b/src/utils/wordwrap.ts index c9ab330..aff4dde 100644 --- a/src/utils/wordwrap.ts +++ b/src/utils/wordwrap.ts @@ -1,3 +1,45 @@ +import { + graphemeWidth, + splitGraphemes, + stringWidth, +} from "./terminal-width.js"; + +function sliceToWidth(text: string, width: number): string { + let result = ""; + let currentWidth = 0; + + for (const grapheme of splitGraphemes(text)) { + const nextWidth = currentWidth + graphemeWidth(grapheme); + if (nextWidth > width) break; + result += grapheme; + currentWidth = nextWidth; + } + + return result; +} + +function splitByWidth(text: string, width: number): string[] { + const lines: string[] = []; + let current = ""; + let currentWidth = 0; + + for (const grapheme of splitGraphemes(text)) { + const glyphWidth = graphemeWidth(grapheme); + if (current && currentWidth + glyphWidth > width) { + lines.push(current); + current = grapheme; + currentWidth = glyphWidth; + continue; + } + + current += grapheme; + currentWidth += glyphWidth; + } + + if (current) lines.push(current); + return lines; +} + export function wordWrap( text: string, width: number, @@ -14,24 +56,31 @@ export function wordWrap( continue; } let current = ""; + let currentWidth = 0; for (const word of words) { - if (word.length > width) { + const wordWidth = stringWidth(word); + + if (wordWidth > width) { if (current) { lines.push(current); current = ""; + currentWidth = 0; } - for (let i = 0; i < word.length; i += width) { - lines.push(word.slice(i, i + width)); + for (const slice of splitByWidth(word, width)) { + lines.push(slice); } continue; } - if (current && current.length + 1 + word.length > width) { + const nextWidth = current ? currentWidth + 1 + wordWidth : wordWidth; + if (current && nextWidth > width) { lines.push(current); current = word; + currentWidth = wordWidth; } else { current = current ? current + " " + word : word; + currentWidth = nextWidth; } } if (current) lines.push(current); @@ -41,7 +90,9 @@ export function wordWrap( const capped = lines.slice(0, maxLines); const last = capped[maxLines - 1]; capped[maxLines - 1] = - last.length >= width ? last.slice(0, width - 1) + "…" : last + "…"; + stringWidth(last) >= width + ? sliceToWidth(last, width - 1) + "…" + : last + "…"; return capped; }