From ce101be0c2147657e981a12d818cb282c7ffd789 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 10:09:04 -0700 Subject: [PATCH 1/8] feat(renderer): randomize star field seeds --- src/renderer.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/renderer.ts | 28 +++++++++++++++++++--------- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/renderer.test.ts b/src/renderer.test.ts index b819f1b..4b62014 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -9,6 +9,7 @@ import { renderMoonStrip, renderStarFieldLines, buildFrame, + buildFrameCells, } from "./renderer.js"; import type { Orchestrator, OrchestratorState } from "./core/orchestrator.js"; @@ -214,6 +215,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 fills the full MAX_MSG_LINE_LEN (64 chars > CONTENT_WIDTH 63) + const longMessage = "A".repeat(64); + + 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); + } + }); }); describe("Renderer ctrl+c", () => { diff --git a/src/renderer.ts b/src/renderer.ts index 7aab26b..a0cddba 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"; @@ -250,10 +250,11 @@ function renderSideStarsCells( } function centerLineCells(content: Cell[], width: number): Cell[] { - const w = content.length; + const clamped = content.length > width ? content.slice(0, width) : content; + 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[] { @@ -433,12 +434,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; }); @@ -501,17 +508,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; } From 0c530217f9c9343744baa98f08e88f74da693f19 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 10:15:39 -0700 Subject: [PATCH 2/8] Airlock: address 1 critique comment(s) --- src/renderer.test.ts | 17 +++++++++++++++++ src/renderer.ts | 25 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 4b62014..1c5ea0e 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, @@ -11,6 +12,7 @@ import { buildFrame, buildFrameCells, } from "./renderer.js"; +import { rowToString, textToCells } from "./renderer-diff.js"; import type { Orchestrator, OrchestratorState } from "./core/orchestrator.js"; describe("renderTitle", () => { @@ -258,6 +260,21 @@ describe("buildFrame", () => { }); }); +describe("clampCellsToWidth", () => { + it("drops an overflowing wide glyph instead of splitting it", () => { + const clamp = ( + renderer as { + clampCellsToWidth?: (cells: ReturnType, width: number) => ReturnType; + } + ).clampCellsToWidth; + + expect(clamp).toBeTypeOf("function"); + expect( + rowToString(clamp?.(textToCells(`${"A".repeat(62)}๐ŸŒ•`, "normal"), 63) ?? []), + ).toBe("A".repeat(62)); + }); +}); + describe("Renderer ctrl+c", () => { it("hides immediately and then requests orchestrator stop", async () => { vi.useFakeTimers(); diff --git a/src/renderer.ts b/src/renderer.ts index a0cddba..008b46c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -249,8 +249,31 @@ function renderSideStarsCells( return cells; } +export 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 clamped = content.length > width ? content.slice(0, width) : content; + 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); From 54cdd1c82f3d424d99811c8ba2ae5319d3668b32 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 10:24:23 -0700 Subject: [PATCH 3/8] Airlock: address 2 critique comment(s) --- src/renderer.test.ts | 12 ++++++++++-- src/renderer.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 1c5ea0e..78e91f7 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -83,6 +83,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", () => { @@ -222,8 +230,8 @@ describe("buildFrame", () => { // Use width where (width - CONTENT_WIDTH) is even so sideWidth*2 + 63 = width const terminalWidth = 83; const terminalHeight = 30; - // Message that fills the full MAX_MSG_LINE_LEN (64 chars > CONTENT_WIDTH 63) - const longMessage = "A".repeat(64); + // Message that overflows CONTENT_WIDTH only because the trailing glyph is 2 cells wide. + const longMessage = `${"A".repeat(62)}๐ŸŒ•`; const state: OrchestratorState = { status: "running", diff --git a/src/renderer.ts b/src/renderer.ts index 008b46c..c5c7106 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 = CONTENT_WIDTH; +const MAX_MSG_LINE_LEN = 64; const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]"; export type RendererExitReason = "interrupted" | "stopped"; From 601512863c1d72d6fc6197375580dfebf3e18a04 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 11:25:42 -0700 Subject: [PATCH 4/8] Airlock: address 1 critique comment(s) --- src/renderer.test.ts | 31 +++++++++++++++++++- src/renderer.ts | 2 +- src/utils/wordwrap.ts | 68 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 78e91f7..a7f91eb 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -89,7 +89,7 @@ describe("renderAgentMessage", () => { renderAgentMessage(`${"A".repeat(62)}๐ŸŒ•`, "running") .map(stripAnsi) .filter(Boolean), - ).toEqual([`${"A".repeat(62)}๐ŸŒ•`]); + ).toEqual(["A".repeat(62), "๐ŸŒ•"]); }); }); @@ -144,6 +144,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", diff --git a/src/renderer.ts b/src/renderer.ts index c5c7106..008b46c 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"; diff --git a/src/utils/wordwrap.ts b/src/utils/wordwrap.ts index c9ab330..3ff32b7 100644 --- a/src/utils/wordwrap.ts +++ b/src/utils/wordwrap.ts @@ -1,3 +1,52 @@ +function charWidth(char: string): number { + const cp = char.codePointAt(0) ?? 0; + return cp > 0xffff ? 2 : 1; +} + +function stringWidth(text: string): number { + let width = 0; + for (const char of text) { + width += charWidth(char); + } + return width; +} + +function sliceToWidth(text: string, width: number): string { + let result = ""; + let currentWidth = 0; + + for (const char of text) { + const nextWidth = currentWidth + charWidth(char); + if (nextWidth > width) break; + result += char; + currentWidth = nextWidth; + } + + return result; +} + +function splitByWidth(text: string, width: number): string[] { + const lines: string[] = []; + let current = ""; + let currentWidth = 0; + + for (const char of text) { + const glyphWidth = charWidth(char); + if (current && currentWidth + glyphWidth > width) { + lines.push(current); + current = char; + currentWidth = glyphWidth; + continue; + } + + current += char; + currentWidth += glyphWidth; + } + + if (current) lines.push(current); + return lines; +} + export function wordWrap( text: string, width: number, @@ -14,24 +63,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 +97,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; } From bc791bcc26091e12bc01401b00bb038fc962867b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 11:40:44 -0700 Subject: [PATCH 5/8] Airlock: address 2 critique comment(s) --- src/renderer-diff.ts | 9 +++-- src/renderer.test.ts | 16 ++------ src/renderer.ts | 2 +- src/utils/terminal-width.ts | 76 +++++++++++++++++++++++++++++++++++++ src/utils/wordwrap.test.ts | 8 ++++ src/utils/wordwrap.ts | 31 ++++++--------- 6 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 src/utils/terminal-width.ts diff --git a/src/renderer-diff.ts b/src/renderer-diff.ts index 22e67ee..2a1d61f 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"; @@ -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 a7f91eb..6bfb61f 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -12,7 +12,6 @@ import { buildFrame, buildFrameCells, } from "./renderer.js"; -import { rowToString, textToCells } from "./renderer-diff.js"; import type { Orchestrator, OrchestratorState } from "./core/orchestrator.js"; describe("renderTitle", () => { @@ -297,18 +296,9 @@ describe("buildFrame", () => { }); }); -describe("clampCellsToWidth", () => { - it("drops an overflowing wide glyph instead of splitting it", () => { - const clamp = ( - renderer as { - clampCellsToWidth?: (cells: ReturnType, width: number) => ReturnType; - } - ).clampCellsToWidth; - - expect(clamp).toBeTypeOf("function"); - expect( - rowToString(clamp?.(textToCells(`${"A".repeat(62)}๐ŸŒ•`, "normal"), 63) ?? []), - ).toBe("A".repeat(62)); +describe("renderer module exports", () => { + it("does not expose clampCellsToWidth", () => { + expect("clampCellsToWidth" in renderer).toBe(false); }); }); diff --git a/src/renderer.ts b/src/renderer.ts index 008b46c..5ad9008 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -249,7 +249,7 @@ function renderSideStarsCells( return cells; } -export function clampCellsToWidth(content: Cell[], width: number): Cell[] { +function clampCellsToWidth(content: Cell[], width: number): Cell[] { if (content.length <= width) return content; const clamped: Cell[] = []; diff --git a/src/utils/terminal-width.ts b/src/utils/terminal-width.ts new file mode 100644 index 0000000..c637436 --- /dev/null +++ b/src/utils/terminal-width.ts @@ -0,0 +1,76 @@ +const graphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme", +}); + +const MARK_REGEX = /\p{Mark}/u; +const REGIONAL_INDICATOR_REGEX = /\p{Regional_Indicator}/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) => (char.codePointAt(0) ?? 0) > 0xffff) + ); +} + +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 3ff32b7..aff4dde 100644 --- a/src/utils/wordwrap.ts +++ b/src/utils/wordwrap.ts @@ -1,24 +1,17 @@ -function charWidth(char: string): number { - const cp = char.codePointAt(0) ?? 0; - return cp > 0xffff ? 2 : 1; -} - -function stringWidth(text: string): number { - let width = 0; - for (const char of text) { - width += charWidth(char); - } - return width; -} +import { + graphemeWidth, + splitGraphemes, + stringWidth, +} from "./terminal-width.js"; function sliceToWidth(text: string, width: number): string { let result = ""; let currentWidth = 0; - for (const char of text) { - const nextWidth = currentWidth + charWidth(char); + for (const grapheme of splitGraphemes(text)) { + const nextWidth = currentWidth + graphemeWidth(grapheme); if (nextWidth > width) break; - result += char; + result += grapheme; currentWidth = nextWidth; } @@ -30,16 +23,16 @@ function splitByWidth(text: string, width: number): string[] { let current = ""; let currentWidth = 0; - for (const char of text) { - const glyphWidth = charWidth(char); + for (const grapheme of splitGraphemes(text)) { + const glyphWidth = graphemeWidth(grapheme); if (current && currentWidth + glyphWidth > width) { lines.push(current); - current = char; + current = grapheme; currentWidth = glyphWidth; continue; } - current += char; + current += grapheme; currentWidth += glyphWidth; } From e513c155203dc4ad79cb0b12bbff779f2587b9f1 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 11:47:09 -0700 Subject: [PATCH 6/8] Airlock: address 1 critique comment(s) --- src/utils/terminal-width.test.ts | 8 ++++++++ src/utils/terminal-width.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/utils/terminal-width.test.ts 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 index c637436..4786826 100644 --- a/src/utils/terminal-width.ts +++ b/src/utils/terminal-width.ts @@ -4,6 +4,7 @@ const graphemeSegmenter = new Intl.Segmenter(undefined, { 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 ( @@ -48,7 +49,9 @@ function isWideEmojiGrapheme(grapheme: string): boolean { grapheme.includes("\ufe0f") || grapheme.includes("\u20e3") || REGIONAL_INDICATOR_REGEX.test(grapheme) || - Array.from(grapheme).some((char) => (char.codePointAt(0) ?? 0) > 0xffff) + Array.from(grapheme).some((char) => + EXTENDED_PICTOGRAPHIC_REGEX.test(char), + ) ); } From a517530ceabdafe0e7fabe950cc00f4c84ad7a9e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 11:59:38 -0700 Subject: [PATCH 7/8] Airlock: auto-fixes from Documentation Updates --- CHANGELOG.md | 10 ++++++++++ README.md | 1 + src/renderer-diff.ts | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) 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 2a1d61f..60ead9d 100644 --- a/src/renderer-diff.ts +++ b/src/renderer-diff.ts @@ -7,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; } From 944855faf1e104c2855e0d3b316bf7d74e14ad5e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 3 Apr 2026 11:59:50 -0700 Subject: [PATCH 8/8] Airlock: auto-fixes from Lint & Format Fixes --- src/utils/terminal-width.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/terminal-width.ts b/src/utils/terminal-width.ts index 4786826..eb4e557 100644 --- a/src/utils/terminal-width.ts +++ b/src/utils/terminal-width.ts @@ -49,9 +49,7 @@ function isWideEmojiGrapheme(grapheme: string): boolean { grapheme.includes("\ufe0f") || grapheme.includes("\u20e3") || REGIONAL_INDICATOR_REGEX.test(grapheme) || - Array.from(grapheme).some((char) => - EXTENDED_PICTOGRAPHIC_REGEX.test(char), - ) + Array.from(grapheme).some((char) => EXTENDED_PICTOGRAPHIC_REGEX.test(char)) ); }