Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.17.3

### Patch Changes

- 9d1c346: Extend static pager diff-row backgrounds to the edge of host panels such as Lazygit.
- 05d6c17: Wrap plain-text agent notes by terminal cells instead of UTF-16 code
units, so CJK and emoji text wraps correctly instead of being truncated
with silent content loss. Long unbroken words split on grapheme
boundaries, so wide characters and surrogate pairs are never cut apart.

## 0.17.2

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hunkdiff",
"version": "0.17.2",
"version": "0.17.3",
"description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.",
"keywords": [
"ai",
Expand Down
34 changes: 26 additions & 8 deletions src/ui/lib/agentPopover.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { sanitizeTerminalLine } from "../../lib/terminalText";
import { fitText } from "./text";
import { fitText, measureTextWidth, sliceTextByWidth } from "./text";

function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

/** Wrap plain text to a fixed terminal width, breaking long tokens when needed. */
/** Wrap plain text to a fixed terminal-cell width, breaking long tokens when needed. */
export function wrapText(text: string, width: number) {
if (width <= 0) {
return [""];
Expand All @@ -19,31 +19,49 @@ export function wrapText(text: string, width: number) {
const words = normalized.split(" ");
const lines: string[] = [];
let current = "";
let currentWidth = 0;

const pushCurrent = () => {
if (current.length > 0) {
lines.push(current);
current = "";
currentWidth = 0;
}
};

for (const word of words) {
if (word.length > width) {
const wordWidth = measureTextWidth(word);

if (wordWidth > width) {
pushCurrent();
for (let offset = 0; offset < word.length; offset += width) {
lines.push(word.slice(offset, offset + width));
let offset = 0;
while (offset < wordWidth) {
const chunk = sliceTextByWidth(word, offset, width);
if (chunk.width <= 0) {
// Width is narrower than one cluster; keep the remainder on one
// line (fitText clamps at render time) instead of dropping it.
const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER);
if (rest.text.length > 0) {
lines.push(rest.text);
}
break;
}
lines.push(chunk.text);
offset += chunk.width;
}
continue;
}

const next = current.length === 0 ? word : `${current} ${word}`;
if (next.length <= width) {
current = next;
const nextWidth = current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth;
if (nextWidth <= width) {
current = current.length === 0 ? word : `${current} ${word}`;
currentWidth = nextWidth;
continue;
}

pushCurrent();
current = word;
currentWidth = wordWidth;
}

pushCurrent();
Expand Down
32 changes: 32 additions & 0 deletions src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,38 @@ describe("ui helpers", () => {
expect(wrapText("alpha beta gamma", 8)).toEqual(["alpha", "beta", "gamma"]);
expect(wrapText("supercalifragilistic", 6)).toEqual(["superc", "alifra", "gilist", "ic"]);

// Wide text wraps by terminal cells, not UTF-16 code units, so CJK lines
// never overflow the box and get clipped by fitText/padText downstream.
expect(wrapText("こんにちは世界", 8)).toEqual(["こんにち", "は世界"]);
expect(wrapText("これは全角文字の長い注釈です", 10)).toEqual([
"これは全角",
"文字の長い",
"注釈です",
]);
expect(wrapText("fix 説明が長い日本語のまま続く", 10)).toEqual([
"fix",
"説明が長い",
"日本語のま",
"ま続く",
]);

// Emoji clusters (surrogate pairs) are never split into lone surrogates.
expect(wrapText("🎉🎉🎉", 4)).toEqual(["🎉🎉", "🎉"]);

// Odd width: a 2-cell character cannot straddle the boundary, so each
// line carries one character even though a cell stays unused.
expect(wrapText("日本語", 3)).toEqual(["日", "本", "語"]);

// Multiple ASCII words still pack into one line when they fit.
expect(wrapText("ab cd", 5)).toEqual(["ab cd"]);

// Width narrower than one cluster keeps the text for fitText to clamp
// at render time instead of silently dropping it.
expect(wrapText("日日", 1)).toEqual(["日日"]);

// ZWJ emoji clusters stay whole when hard-splitting.
expect(wrapText("🧑‍💻🧑‍💻", 2)).toEqual(["🧑‍💻", "🧑‍💻"]);

const content = buildAgentPopoverContent({
summary: "Guard missing socket path",
rationale: "Prevents noisy reconnect errors during first launch.",
Expand Down
24 changes: 18 additions & 6 deletions src/ui/staticDiffPager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ function stripAnsi(text: string) {
return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
}

/** Remove Hunk's intentional SGR color codes while leaving unsafe controls visible. */
function stripColorSgr(text: string) {
return text.replace(/\x1b\[[0-9;]*m/g, "");
/** Remove Hunk's intentional color and line-fill codes while leaving unsafe controls visible. */
function stripIntentionalAnsi(text: string) {
return text.replace(/\x1b(?:\[[0-9;]*m|\[K)/g, "");
}

const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07";
Expand Down Expand Up @@ -95,6 +95,18 @@ describe("static diff pager", () => {
expect(plain).toContain("▌ 1 + const value = 2;");
});

test("extends stacked row backgrounds to the host panel edge", async () => {
const patchText =
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-short\n+also short\n";

const output = await renderStaticDiffPager(patchText);
const changedLines = output.split("\n").filter((line) => stripAnsi(line).includes("short"));
const backgroundFill = /\x1b\[48;2;\d+;\d+;\d+m\x1b\[K\x1b\[0m$/;

expect(changedLines).toHaveLength(2);
expect(changedLines.every((line) => backgroundFill.test(line))).toBe(true);
});

test("uses configured custom themes in static pager output", async () => {
const patchText =
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n";
Expand Down Expand Up @@ -180,7 +192,7 @@ describe("static diff pager", () => {
"",
].join("\n");

const output = stripColorSgr(
const output = stripIntentionalAnsi(
await renderStaticDiffPager(text, {}, { stderr: { write: () => true } }),
);

Expand All @@ -199,7 +211,7 @@ describe("static diff pager", () => {
"",
].join("\n");

const output = stripColorSgr(await renderStaticDiffPager(patchText));
const output = stripIntentionalAnsi(await renderStaticDiffPager(patchText));

expect(output).toContain("evil");
expect(output).toContain("@@ -1 +1 @@");
Expand All @@ -217,7 +229,7 @@ describe("static diff pager", () => {
"",
].join("\n");

const output = stripColorSgr(await renderStaticDiffPager(patchText));
const output = stripIntentionalAnsi(await renderStaticDiffPager(patchText));

expectNoUnsafeTerminalControls(output);
});
Expand Down
8 changes: 7 additions & 1 deletion src/ui/staticDiffPager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ function colorText(text: string, fg?: string, bg?: string) {
return prefix ? `${prefix}${safeText}${RESET}` : safeText;
}

/** Extend one row background to the host panel edge without assuming the panel width. */
function fillRemainingLine(bg: string) {
const background = ansiColor("bg", bg);
return background ? `${background}\x1b[K${RESET}` : "";
}

/** Serialize highlighted code spans into ANSI text, preserving a row background when present. */
function serializeSpans(spans: RenderSpan[], rowBg: string) {
return spans.map((span) => colorText(span.text, span.fg, span.bg ?? rowBg)).join("");
Expand Down Expand Up @@ -157,7 +163,7 @@ function renderStaticStackRow(
staticStackGutterText(cell, lineNumberWidth, options.lineNumbers !== false),
palette.numberColor,
palette.gutterBg,
)}${serializeSpans(cell.spans, palette.contentBg)}`;
)}${serializeSpans(cell.spans, palette.contentBg)}${fillRemainingLine(palette.contentBg)}`;
}

function renderStaticSplitCell(
Expand Down