Skip to content

fix(ui): align split diffs with wide characters#353

Merged
benvinegar merged 1 commit into
mainfrom
fix/wide-character-width
May 23, 2026
Merged

fix(ui): align split diffs with wide characters#353
benvinegar merged 1 commit into
mainfrom
fix/wide-character-width

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Summary

  • Fix split diff alignment for CJK/emoji-width text by measuring and slicing rendered spans in terminal cells.
  • Add ASCII fast paths and cache max code-line width per diff metadata object to keep common diffs cheap.
  • Add unit/render regression coverage plus a Tuistory PTY test for wide-character separator alignment.

Closes #324

Tests

  • bun run typecheck
  • bun run lint
  • bun test
  • bun test test/pty/ui-integration.test.ts --test-name-pattern "wide characters"

This PR description was generated by Pi using OpenAI ChatGPT

@socket-security

socket-security Bot commented May 23, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedstring-width@​8.2.11001008987100

View full report

@greptile-apps

greptile-apps Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes split-diff separator misalignment when code contains CJK characters or emoji by replacing all character-count-based width measurements and string slices with terminal-cell-aware equivalents backed by a new measureTextWidth / sliceTextByWidth API in src/ui/lib/text.ts.

  • measureTextWidth delegates to string-width for non-ASCII text and uses a plain .length fast-path for printable ASCII; sliceTextByWidth iterates grapheme clusters (via Intl.Segmenter when available) to avoid splitting wide characters.
  • codeColumns.ts switches to cell-based measurement and adds a WeakMap cache keyed on file.metadata so the per-file max-width scan runs at most once per diff object.
  • renderRows.tsx updates all sliceSpansWindow, wrapSpans, spansToPlainText, fitText, and the normalizedLeft/Right padding in renderDecoratedPlannedRowText to use the new helpers, ensuring the center separator always lands at exactly leftWidth terminal cells regardless of content width.

Confidence Score: 4/5

Safe to merge for the stated fix; the separator alignment path is correct and well-covered by both unit and PTY tests.

The core alignment fix is correct and thoroughly tested. The one rough edge is in sliceSpansWindow: switching the skip guard from span.text.length to measureTextWidth(span.text) means spans with zero visual width are now always dropped even at offset 0, whereas before they would have been passed through. Real-world impact is negligible because syntax highlighters don't emit combining-mark-only spans, but it is an untested behavioral change.

src/ui/diff/renderRows.tsx — specifically the sliceSpansWindow skip guard and the analogous loop guard in wrapSpans.

Important Files Changed

Filename Overview
src/ui/lib/text.ts New module introducing measureTextWidth, sliceTextByWidth, and updates to fitText/padText — the ASCII fast-path and grapheme-segmenter logic are correct; cell arithmetic is consistent throughout.
src/ui/diff/renderRows.tsx Wide-character-aware cell slicing and padding applied throughout; separator alignment fix is correct, but sliceSpansWindow unconditionally skips zero-visual-width spans, a regression from the pre-PR character-length guard.
src/ui/diff/codeColumns.ts Adds WeakMap cache keyed on file.metadata for maxFileCodeLineWidth, and switches measurement to measureTextWidth for correct terminal-cell counting of CJK/emoji.
src/ui/diff/pierre.test.ts New integration-style test verifies separator cell alignment for a CJK deletion row; uses measureTextWidth on the sliced line which correctly handles ANSI-stripped content.
test/pty/ui-integration.test.ts PTY-level regression test validates that stringWidth of the left half of both a wide-char row and a plain row match, giving end-to-end confidence in the separator alignment fix.

Sequence Diagram

sequenceDiagram
    participant DF as DiffFile
    participant CC as codeColumns.ts
    participant RR as renderRows.tsx
    participant TXT as text.ts

    DF->>CC: maxFileCodeLineWidth(file)
    CC->>CC: check WeakMap cache (file.metadata)
    CC->>TXT: measureTextWidth(expandDiffTabs(line))
    TXT->>TXT: ASCII fast path (.length) or stringWidth()
    TXT-->>CC: terminal cell count
    CC-->>CC: cache result in WeakMap
    CC-->>RR: max column width (terminal cells)

    RR->>RR: buildPlainSplitCellLines(cell, leftWidth)
    RR->>TXT: sliceTextByWidth(joined spans, offset, contentWidth)
    TXT->>TXT: grapheme cluster iteration via Intl.Segmenter
    TXT-->>RR: "{ text, width } — no wide char split"
    RR->>TXT: padText(visible.text, contentWidth)
    TXT-->>RR: exactly contentWidth terminal cells

    RR->>TXT: padText(leftPrefix + spansText, leftWidth)
    TXT-->>RR: normalizedLeft — exactly leftWidth cells
    Note over RR: separator always at leftWidth terminal cells
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/ui/diff/renderRows.tsx:66-76
**Zero-width spans silently dropped after offset refactor**

`sliceSpansWindow` skips any span where `measureTextWidth(span.text) === 0` unconditionally. Because `remainingOffset` is always `≥ 0`, the guard `remainingOffset >= spanWidth` is always `true` for zero-visual-width spans (e.g., a span whose text is only combining marks such as `\u0301`). Pre-PR the check was against `span.text.length`, so a non-empty span with `length ≥ 1` but zero visual width was only skipped while the initial offset was still being consumed. After this change, such spans are permanently dropped even at `offset = 0`. The same issue appears in `wrapSpans` at the analogous `while (offset < spanWidth)` guard — that loop never runs for a span with `spanWidth === 0`.

In practice, syntax highlighters don't emit spans containing only combining characters, so real-world impact is minimal. But the contract change is worth documenting, or guarding with an explicit `spanWidth === 0` branch that passes the span through unconditionally.

Reviews (1): Last reviewed commit: "fix(ui): align split diffs with wide cha..." | Re-trigger Greptile

Comment on lines +66 to 76
const spanWidth = measureTextWidth(span.text);
if (remainingOffset >= spanWidth) {
remainingOffset -= spanWidth;
continue;
}

const start = remainingOffset;
const text = span.text.slice(start, start + remaining);
const visible = sliceTextByWidth(span.text, remainingOffset, remaining);
remainingOffset = 0;

if (text.length === 0) {
if (visible.text.length === 0) {
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Zero-width spans silently dropped after offset refactor

sliceSpansWindow skips any span where measureTextWidth(span.text) === 0 unconditionally. Because remainingOffset is always ≥ 0, the guard remainingOffset >= spanWidth is always true for zero-visual-width spans (e.g., a span whose text is only combining marks such as \u0301). Pre-PR the check was against span.text.length, so a non-empty span with length ≥ 1 but zero visual width was only skipped while the initial offset was still being consumed. After this change, such spans are permanently dropped even at offset = 0. The same issue appears in wrapSpans at the analogous while (offset < spanWidth) guard — that loop never runs for a span with spanWidth === 0.

In practice, syntax highlighters don't emit spans containing only combining characters, so real-world impact is minimal. But the contract change is worth documenting, or guarding with an explicit spanWidth === 0 branch that passes the span through unconditionally.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/diff/renderRows.tsx
Line: 66-76

Comment:
**Zero-width spans silently dropped after offset refactor**

`sliceSpansWindow` skips any span where `measureTextWidth(span.text) === 0` unconditionally. Because `remainingOffset` is always `≥ 0`, the guard `remainingOffset >= spanWidth` is always `true` for zero-visual-width spans (e.g., a span whose text is only combining marks such as `\u0301`). Pre-PR the check was against `span.text.length`, so a non-empty span with `length ≥ 1` but zero visual width was only skipped while the initial offset was still being consumed. After this change, such spans are permanently dropped even at `offset = 0`. The same issue appears in `wrapSpans` at the analogous `while (offset < spanWidth)` guard — that loop never runs for a span with `spanWidth === 0`.

In practice, syntax highlighters don't emit spans containing only combining characters, so real-world impact is minimal. But the contract change is worth documenting, or guarding with an explicit `spanWidth === 0` branch that passes the span through unconditionally.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I added explicit zero-width span handling so combining-mark-only spans are preserved in both the nowrap slicing path and wrapped row path, and added a DiffRowView regression test that exercises both modes.

This comment was generated by Pi using OpenAI ChatGPT

@benvinegar benvinegar force-pushed the fix/wide-character-width branch 2 times, most recently from 73dcea1 to 9ecee33 Compare May 23, 2026 17:58
@benvinegar benvinegar force-pushed the fix/wide-character-width branch from 9ecee33 to 9e55d24 Compare May 23, 2026 18:07
@benvinegar benvinegar merged commit bf4ad9b into main May 23, 2026
5 checks passed
@benvinegar benvinegar deleted the fix/wide-character-width branch May 23, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wide charactors (Japanese, Chinese, Korean, etc...) issue

1 participant