diff --git a/src/ui/AppHost.scroll-regression.test.tsx b/src/ui/AppHost.scroll-regression.test.tsx
index a39e7dcb..81685188 100644
--- a/src/ui/AppHost.scroll-regression.test.tsx
+++ b/src/ui/AppHost.scroll-regression.test.tsx
@@ -34,6 +34,27 @@ function createScrollBootstrap(): AppBootstrap {
});
}
+function createCjkUntrackedScrollBootstrap(): AppBootstrap {
+ const cjkPhrase = "這是一段很長的中文內容用來驗證分割視圖滑鼠捲動";
+ const after = Array.from(
+ { length: 40 },
+ (_, index) => `第${String(index + 1).padStart(2, "0")}行${cjkPhrase.repeat(5)}\n`,
+ ).join("");
+
+ return createTestVcsAppBootstrap({
+ changesetId: "scroll-regression-cjk",
+ files: [
+ createTestDiffFile({
+ after,
+ before: "",
+ context: 3,
+ id: "cjk-new",
+ path: "notes.md",
+ }),
+ ],
+ });
+}
+
describe("UI scroll regression", () => {
test("keeps split diff lines intact after a wheel scroll repaint", async () => {
const setup = await testRender(, {
@@ -69,4 +90,33 @@ describe("UI scroll regression", () => {
});
}
});
+
+ test("clips CJK split additions after a wheel scroll repaint", async () => {
+ const setup = await testRender(, {
+ width: 80,
+ height: 14,
+ });
+
+ try {
+ await act(async () => {
+ await setup.renderOnce();
+ await Bun.sleep(100);
+ await setup.renderOnce();
+ });
+
+ await act(async () => {
+ await setup.mockMouse.scroll(50, 8, "down");
+ await Bun.sleep(0);
+ await setup.renderOnce();
+ });
+
+ const scrolledFrame = setup.captureCharFrame();
+ expect(scrolledFrame).toContain("▌ 4 + 第04行這是一段很長的中文");
+ expect(scrolledFrame).not.toMatch(/\n[^\S\r\n]*滑鼠捲動\s*\n/);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
});
diff --git a/src/ui/diff/codeColumns.test.ts b/src/ui/diff/codeColumns.test.ts
index c0857fff..c0372319 100644
--- a/src/ui/diff/codeColumns.test.ts
+++ b/src/ui/diff/codeColumns.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { DiffFile } from "../../core/types";
-import { maxFileCodeLineWidth } from "./codeColumns";
+import { maxFileCodeLineWidth, measureRenderedCodeLineWidth } from "./codeColumns";
/** Generate a large diff metadata fixture without checking a huge file into the repo. */
function createLargeLineFixture(lineCount: number, widestLine: string): DiffFile {
@@ -28,4 +28,13 @@ describe("code column measurement", () => {
expect(maxFileCodeLineWidth(file)).toBe("the widest generated line".length);
});
+
+ test("measures CJK lines in terminal cells", () => {
+ const file = createLargeLineFixture(3, "かなカナ漢字 mixed");
+
+ expect(measureRenderedCodeLineWidth("中文 mixed")).toBe(10);
+ expect(measureRenderedCodeLineWidth("かなカナ漢字 mixed")).toBe(18);
+ expect(measureRenderedCodeLineWidth("한글 테스트 mixed")).toBe(17);
+ expect(maxFileCodeLineWidth(file)).toBe(18);
+ });
});
diff --git a/src/ui/diff/codeColumns.ts b/src/ui/diff/codeColumns.ts
index ebf080df..46984e03 100644
--- a/src/ui/diff/codeColumns.ts
+++ b/src/ui/diff/codeColumns.ts
@@ -1,4 +1,5 @@
import type { DiffFile, LayoutMode } from "../../core/types";
+import { terminalCellWidth } from "../lib/text";
export const DIFF_CODE_TAB_WIDTH = 2;
export const DIFF_RAIL_PREFIX_WIDTH = 1;
@@ -11,7 +12,7 @@ export function expandDiffTabs(text: string) {
/** Measure one rendered code line after tab expansion and newline trimming. */
export function measureRenderedCodeLineWidth(line: string | undefined) {
- return expandDiffTabs((line ?? "").replace(/\n$/, "")).length;
+ return terminalCellWidth(expandDiffTabs((line ?? "").replace(/\n$/, "")));
}
/** Track the widest rendered code line for one file. */
diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx
index 3576e074..a22e09ff 100644
--- a/src/ui/diff/renderRows.tsx
+++ b/src/ui/diff/renderRows.tsx
@@ -1,5 +1,6 @@
import { memo, type ReactNode } from "react";
import type { DiffFile } from "../../core/types";
+import { sliceTextByTerminalCells, terminalCellWidth } from "../lib/text";
import type { AppTheme } from "../themes";
import {
resolveSplitCellGeometry,
@@ -25,7 +26,7 @@ export function fitText(text: string, width: number) {
return "";
}
- if (text.length <= width) {
+ if (terminalCellWidth(text) <= width) {
return text;
}
@@ -33,7 +34,7 @@ export function fitText(text: string, width: number) {
return "…";
}
- return `${text.slice(0, width - 1)}…`;
+ return `${sliceTextByTerminalCells(text, 0, width - 1).text}…`;
}
/** Slice styled spans to one visible window while preserving color runs. */
@@ -55,16 +56,24 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) {
break;
}
- if (remainingOffset >= span.text.length) {
- remainingOffset -= span.text.length;
+ const spanWidth = terminalCellWidth(span.text);
+ if (remainingOffset >= spanWidth) {
+ remainingOffset -= spanWidth;
continue;
}
- const start = remainingOffset;
- const text = span.text.slice(start, start + remaining);
+ const offsetBeforeSlice = remainingOffset;
+ const {
+ clipped,
+ text,
+ width: textWidth,
+ } = sliceTextByTerminalCells(span.text, remainingOffset, remaining);
remainingOffset = 0;
if (text.length === 0) {
+ if (clipped && offsetBeforeSlice === 0) {
+ break;
+ }
continue;
}
@@ -80,8 +89,11 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) {
sliced.push(nextSpan);
}
- remaining -= text.length;
- usedWidth += text.length;
+ remaining -= textWidth;
+ usedWidth += textWidth;
+ if (clipped) {
+ break;
+ }
}
return {
@@ -160,36 +172,62 @@ function wrapSpans(spans: RenderSpan[], width: number) {
const lines: RenderSpan[][] = [[]];
let current = lines[0]!;
- let remaining = width;
+ let currentWidth = 0;
+
+ const startNextLine = () => {
+ current = [];
+ lines.push(current);
+ currentWidth = 0;
+ };
+
+ const appendToCurrentLine = (span: RenderSpan, text: string, textWidth: number) => {
+ if (text.length === 0) {
+ return;
+ }
+
+ const nextSpan = {
+ ...span,
+ text,
+ };
+ const previous = current.at(-1);
+ if (previous && previous.fg === nextSpan.fg && previous.bg === nextSpan.bg) {
+ previous.text += nextSpan.text;
+ } else {
+ current.push(nextSpan);
+ }
+
+ currentWidth += textWidth;
+ };
for (const span of spans) {
- let offset = 0;
+ for (let index = 0; index < span.text.length; ) {
+ const codePoint = span.text.codePointAt(index);
+ if (codePoint === undefined) {
+ break;
+ }
+
+ const text = String.fromCodePoint(codePoint);
+ const textWidth = terminalCellWidth(text);
+ index += codePoint > 0xffff ? 2 : 1;
- while (offset < span.text.length) {
- if (remaining <= 0) {
- current = [];
- lines.push(current);
- remaining = width;
+ if (textWidth === 0) {
+ appendToCurrentLine(span, text, 0);
+ continue;
}
- const text = span.text.slice(offset, offset + remaining);
- if (text.length === 0) {
- break;
+ if (textWidth > width) {
+ if (currentWidth > 0) {
+ startNextLine();
+ }
+ appendToCurrentLine(span, "…", 1);
+ continue;
}
- const nextSpan = {
- ...span,
- text,
- };
- const previous = current.at(-1);
- if (previous && previous.fg === nextSpan.fg && previous.bg === nextSpan.bg) {
- previous.text += nextSpan.text;
- } else {
- current.push(nextSpan);
+ if (currentWidth + textWidth > width) {
+ startNextLine();
}
- offset += text.length;
- remaining -= text.length;
+ appendToCurrentLine(span, text, textWidth);
}
}
diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts
index a5a5760e..ad88145b 100644
--- a/src/ui/lib/text.ts
+++ b/src/ui/lib/text.ts
@@ -1,10 +1,144 @@
+/** Return whether a Unicode code point has zero visible terminal width. */
+function isZeroWidthCodePoint(codePoint: number) {
+ return (
+ codePoint === 0 ||
+ codePoint === 0x200b ||
+ codePoint === 0x200c ||
+ codePoint === 0x200d ||
+ codePoint === 0xfeff ||
+ (codePoint >= 0x0001 && codePoint <= 0x001f) ||
+ (codePoint >= 0x007f && codePoint <= 0x009f) ||
+ (codePoint >= 0x0300 && codePoint <= 0x036f) ||
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
+ );
+}
+
+/** Return whether a Unicode code point normally occupies two terminal cells. */
+function isWideCodePoint(codePoint: number) {
+ return (
+ codePoint >= 0x1100 &&
+ (codePoint <= 0x115f ||
+ codePoint === 0x2329 ||
+ codePoint === 0x232a ||
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd))
+ );
+}
+
+/** Measure one Unicode code point in terminal cells. */
+function codePointCellWidth(codePoint: number) {
+ if (isZeroWidthCodePoint(codePoint)) {
+ return 0;
+ }
+
+ return isWideCodePoint(codePoint) ? 2 : 1;
+}
+
+/** Measure rendered text in terminal cells, counting CJK/fullwidth characters as two cells. */
+export function terminalCellWidth(text: string) {
+ let width = 0;
+
+ for (let index = 0; index < text.length; ) {
+ const codePoint = text.codePointAt(index);
+ if (codePoint === undefined) {
+ break;
+ }
+
+ width += codePointCellWidth(codePoint);
+ index += codePoint > 0xffff ? 2 : 1;
+ }
+
+ return width;
+}
+
+/** Slice text to a visible terminal-cell window without splitting fullwidth characters. */
+export function sliceTextByTerminalCells(text: string, offset: number, width: number) {
+ if (width <= 0) {
+ return { clipped: terminalCellWidth(text) > Math.max(0, offset), text: "", width: 0 };
+ }
+
+ const windowStart = Math.max(0, offset);
+ const windowEnd = windowStart + width;
+ let cellCursor = 0;
+ let output = "";
+ let usedWidth = 0;
+ let clipped = false;
+ let includedPreviousVisibleCodePoint = false;
+
+ for (let index = 0; index < text.length; ) {
+ const codePoint = text.codePointAt(index);
+ if (codePoint === undefined) {
+ break;
+ }
+
+ const char = String.fromCodePoint(codePoint);
+ const charWidth = codePointCellWidth(codePoint);
+ const nextCellCursor = cellCursor + charWidth;
+ index += codePoint > 0xffff ? 2 : 1;
+
+ if (charWidth === 0) {
+ if (
+ includedPreviousVisibleCodePoint ||
+ (output.length > 0 && cellCursor >= windowStart && cellCursor <= windowEnd)
+ ) {
+ output += char;
+ }
+ continue;
+ }
+
+ if (nextCellCursor <= windowStart) {
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = false;
+ continue;
+ }
+
+ // If the requested window starts in the middle of a fullwidth glyph, omit that glyph entirely.
+ if (cellCursor < windowStart) {
+ const hiddenCellWidth = Math.min(nextCellCursor, windowEnd) - windowStart;
+ if (hiddenCellWidth > 0) {
+ output += " ".repeat(hiddenCellWidth);
+ usedWidth += hiddenCellWidth;
+ }
+
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = false;
+ continue;
+ }
+
+ if (cellCursor >= windowEnd || nextCellCursor > windowEnd) {
+ clipped = true;
+ break;
+ }
+
+ output += char;
+ usedWidth += charWidth;
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = true;
+ }
+
+ return { clipped, text: output, width: usedWidth };
+}
+
/** Clamp text to a fixed width using a plain-dot terminal fallback marker. */
export function fitText(text: string, width: number) {
if (width <= 0) {
return "";
}
- if (text.length <= width) {
+ if (terminalCellWidth(text) <= width) {
return text;
}
@@ -12,11 +146,11 @@ export function fitText(text: string, width: number) {
return ".";
}
- return `${text.slice(0, width - 1)}.`;
+ return `${sliceTextByTerminalCells(text, 0, width - 1).text}.`;
}
/** Clamp and then right-pad text to an exact width. */
export function padText(text: string, width: number) {
const trimmed = fitText(text, width);
- return trimmed.padEnd(width, " ");
+ return `${trimmed}${" ".repeat(Math.max(0, width - terminalCellWidth(trimmed)))}`;
}
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index 98c77b8f..f074595f 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -21,7 +21,7 @@ import {
isStepDownKey,
isStepUpKey,
} from "./keyboard";
-import { fitText, padText } from "./text";
+import { fitText, padText, sliceTextByTerminalCells, terminalCellWidth } from "./text";
import { computeHunkRevealScrollTop } from "./hunkScroll";
import { estimateDiffSectionBodyRows, measureDiffSectionGeometry } from "./diffSectionGeometry";
import { resizeSidebarWidth } from "./sidebar";
@@ -206,6 +206,36 @@ describe("ui helpers", () => {
expect(fitText("hello", 4)).toBe("hel.");
expect(padText("hello", 4)).toBe("hel.");
expect(padText("ok", 4)).toBe("ok ");
+ expect(terminalCellWidth("中文 mixed")).toBe(10);
+ expect(terminalCellWidth("かなカナ漢字 mixed")).toBe(18);
+ expect(terminalCellWidth("한글 테스트 mixed")).toBe(17);
+ expect(sliceTextByTerminalCells("中文 mixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "中文 ",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("かなmixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "かなm",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("한글 mixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "한글 ",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("中", 1, 3)).toEqual({
+ clipped: false,
+ text: " ",
+ width: 1,
+ });
+ expect(sliceTextByTerminalCells("中文", 1, 3)).toEqual({
+ clipped: false,
+ text: " 文",
+ width: 3,
+ });
+ expect(fitText("中文 mixed", 6)).toBe("中文 .");
+ expect(padText("中文", 5)).toBe("中文 ");
});
test("agent popover helpers wrap text and right-align the card within the viewport", () => {
diff --git a/test/pty/harness.ts b/test/pty/harness.ts
index 5e841642..6134ead5 100644
--- a/test/pty/harness.ts
+++ b/test/pty/harness.ts
@@ -201,6 +201,27 @@ export function createPtyHarness() {
return { dir, before, after };
}
+ function createUntrackedCjkRepoFixture() {
+ const dir = makeTempDir("hunk-tuistory-cjk-");
+
+ runGit(["init"], dir);
+ runGit(["config", "user.name", "Pi"], dir);
+ runGit(["config", "user.email", "pi@example.com"], dir);
+ writeText(join(dir, "README.md"), "# fixture\n");
+ runGit(["add", "."], dir);
+ runGit(["commit", "-m", "initial"], dir);
+
+ const cjkPhrase = "這是一段很長的中文內容用來驗證分割視圖滑鼠捲動";
+ const after =
+ Array.from(
+ { length: 40 },
+ (_, index) => `第${String(index + 1).padStart(2, "0")}行${cjkPhrase.repeat(5)}`,
+ ).join("\n") + "\n";
+ writeText(join(dir, "notes.md"), after);
+
+ return { dir };
+ }
+
function createGitRepoFixture(files: ChangedFileSpec[]) {
const dir = makeTempDir("hunk-tuistory-repo-");
@@ -523,6 +544,7 @@ export function createPtyHarness() {
createPagerPatchFixture,
createPinnedHeaderRepoFixture,
createScrollableFilePair,
+ createUntrackedCjkRepoFixture,
createSidebarJumpRepoFixture,
createTwoFileRepoFixture,
launchHunk,
diff --git a/test/pty/ui-integration.test.ts b/test/pty/ui-integration.test.ts
index cd769913..3adf7598 100644
--- a/test/pty/ui-integration.test.ts
+++ b/test/pty/ui-integration.test.ts
@@ -1093,6 +1093,35 @@ describe("live UI integration", () => {
}
});
+ test("mouse wheel scrolling keeps untracked CJK additions inside split panes", async () => {
+ const fixture = harness.createUntrackedCjkRepoFixture();
+ const session = await harness.launchHunk({
+ args: ["diff", "--mode", "split", "--no-wrap"],
+ cwd: fixture.dir,
+ cols: 80,
+ rows: 14,
+ });
+
+ try {
+ await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ timeout: 15_000,
+ });
+
+ await session.waitIdle({ timeout: 200 });
+ await session.scrollDown(1);
+ const scrolled = await harness.waitForSnapshot(
+ session,
+ (text) => text.includes("notes.md (untracked)") && text.includes("第04行"),
+ 5_000,
+ );
+
+ expect(scrolled).toContain("▌ 4 + 第04行這是一段很長的中文");
+ expect(scrolled).not.toMatch(/\n[^\S\r\n]*滑鼠捲動\s*\n/);
+ } finally {
+ session.close();
+ }
+ });
+
test("arrow-key horizontal scrolling reveals hidden code columns in a real PTY", async () => {
const fixture = harness.createLongWrapFilePair();
const session = await harness.launchHunk({