Skip to content
Open
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
57 changes: 57 additions & 0 deletions packages/core/src/utils/textWrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { stringWidth } from './unicode';

export interface WrapOptions {
hard?: boolean;
trim?: boolean;
}
Comment on lines +3 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

hard option has no effect; word-boundary preservation is coincidental.

WrapOptions.hard (line 4) is never read in wrapTextWithWidth. The wrap loop (lines 42-49) always breaks strictly on cumulative cluster width, with no lookback for a word boundary. Passing hard: true or hard: false (or omitting it) produces identical output.

This also means the "without breaking words unnecessarily" behavior exercised by the ASCII test in textWrap.test.ts (lines 5-9) only holds because the wrap point happens to land on a space in that specific input. For example, wrapping 'Hello Worldss Test' at width 12 would split 'Worldss' mid-word, since there is no word-boundary logic. Either implement soft-wrap behavior gated by hard (scan backward for the last whitespace cluster before the width limit when hard is falsy) or remove the unused option from the public interface until it is implemented.

Also applies to: 42-49

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/utils/textWrap.ts` around lines 3 - 6, The WrapOptions.hard
setting is ignored by wrapTextWithWidth, so soft wrapping does not preserve word
boundaries. Update wrapTextWithWidth’s wrap loop to scan backward for the last
whitespace cluster before the width limit when hard is falsy, while retaining
strict cluster-width breaks when hard is true; preserve existing behavior when
no suitable boundary exists.


/**
* Wrap text into lines of specified maximum visual column width.
* Preserves multi-byte Unicode emoji and CJK character boundaries without splitting (#3339).
*/
export function wrapTextWithWidth(
text: string,
maxWidth: number,
options: WrapOptions = {}
): string[] {
if (!text || maxWidth <= 0) return [];

// Segment text into grapheme clusters using Intl.Segmenter if available
let clusters: string[] = [];
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
clusters = Array.from(segmenter.segment(text)).map((s) => s.segment);
} else {
clusters = Array.from(text);
}
Comment on lines +19 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '(^|/)textWrap\.ts$|(^|/)unicode\.ts$' || true

echo "== textWrap outline/contents =="
while IFS= read -r f; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done < <(git ls-files | rg '(^|/)textWrap\.ts$')

echo "== unicode outline/contents =="
while IFS= read -r f; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done < <(git ls-files | rg '(^|/)unicode\.ts$')

echo "== usages =="
rg -n "wrapTextWithWidth|stringWidth|Intl\.Segmenter|Segmenter" .

Repository: Karanjot786/TermUI

Length of output: 331


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runtime behavior probe: ANSI clustering and stringWidth state =="
node - <<'JS'
function stringWidth(s) {
  let total = 0;
  let inEscape = false;
  let codepoints = Array.from(s);
  for (let c of codepoints) {
    if (c === '\x1B') {
      inEscape = true;
      continue;
    }
    if (inEscape) {
      if (c === '[') continue;
      if (/[0-9;]*[A-Za-z]/.test(c)) {
        inEscape = false;
        continue;
      }
    }
    total += codepointWidth(c);
  }
  return total;
}
function codepointWidth(c) {
  return /\p{Wide}/u.test(c) ? 2 : 1;
}

const esc = '\x1B[31m';
const graphemebroken = `red${esc}text`;
const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'});
const clusters = Array.from(segmenter.segment(graphemebroken), s => s.segment);
console.log(JSON.stringify({ clusters, fullEscStripWidth: graphemebroken.length - esc.length, perClusterWidth: clusters.map(c => ({ c: c.replace(/\x1B/g,'<ESC>'), width: stringWidth(c) })) }));
JS

Repository: Karanjot786/TermUI

Length of output: 861


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file sizes =="
wc -l packages/core/src/utils/textWrap.ts packages/core/src/utils/unicode.ts

echo "== textWrap.ts =="
cat -n packages/core/src/utils/textWrap.ts

echo "== unicode.ts =="
cat -n packages/core/src/utils/unicode.ts

echo "== usages =="
rg -n "wrapTextWithWidth|stringWidth|Intl\.Segmenter|Segmenter" packages || true

echo "== runtime behavior probe (fixed regex and fd-style loop) =="
node - <<'JS'
function stringWidth(s, includeEscaped) {
  includeEscaped = !!includeEscaped;
  let total = 0;
  let inEscape = false;
  for (const c of [...s]) {
    if (c === '\x1B') {
      inEscape = true;
      if (includeEscaped) total += 1;
      continue;
    }
    if (inEscape) {
      if (c === '[') continue;
      if (/^[0-9;]*[A-Za-z]$/.test(c)) {
        inEscape = false;
        continue;
      }
    }
    // No grapheme-cluster expansion here: Node supports \p{GraphemeExtend} only.
    total += 1;
  }
  return total;
}

const esc = '\x1B[31m';
const graphemebroken = `red${esc}text`;
const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'});
const segments = Array.from(segmenter.segment(graphemebroken), s => s.segment);
const tokenWidth = segments.map((c, idx) => ({
  idx,
  c: c === '\x1B' ? '<ESC>' : c,
  width: stringWidth(c),
  inEscape: idx < 3 || (idx === 4 && c === 't')
}));
console.log(JSON.stringify({
  segmentsLength: segments.length,
  sequenceStartsAt: segments.findIndex(s => s === '\x1B'),
  wholeEscWidth: stringWidth(esc),
  segmentsAfterTokenization: segments.map((c, idx) => ({
    char: (c === '\x1B' ? '<ESC>' : c) + (idx === 3 && c === ']' ? '' : ''),
    width: stringWidth(c)
  })),
  totalWidthAsSeparated: segments.reduce((sum, c) => sum + stringWidth(c), 0),
  tokenWidth
}));
JS

Repository: Karanjot786/TermUI

Length of output: 50374


Keep ANSI escape sequences atomic during text wrapping.

Grapheme segmentation splits escape sequences such as \x1B[31m into separate characters. Each character is then passed to the single-character measurement path, so the trailing part can be counted as visible text and appended as ordinary characters. This miscounts wrap length and can break the preserved ANSI code in output.

Measure escape sequences as zero-width atomic tokens and avoid splitting them across wrapped lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/utils/textWrap.ts` around lines 19 - 26, Update the
grapheme-cluster construction in the text-wrapping utility to recognize ANSI
escape sequences as zero-width atomic tokens before or during segmentation.
Preserve each complete sequence as one token, exclude it from visible-width
measurement, and ensure wrapping never splits a sequence across lines while
retaining it in the output.


const lines: string[] = [];
let currentLine = '';
let currentLineWidth = 0;

for (const cluster of clusters) {
if (cluster === '\n') {
lines.push(options.trim ? currentLine.trimEnd() : currentLine);
currentLine = '';
currentLineWidth = 0;
continue;
}

const clusterWidth = stringWidth(cluster);

if (currentLineWidth + clusterWidth > maxWidth && currentLine.length > 0) {
lines.push(options.trim ? currentLine.trimEnd() : currentLine);
currentLine = cluster;
currentLineWidth = clusterWidth;
} else {
currentLine += cluster;
currentLineWidth += clusterWidth;
}
}

if (currentLine.length > 0 || text.endsWith('\n')) {
lines.push(options.trim ? currentLine.trimEnd() : currentLine);
}

return lines;
}
31 changes: 31 additions & 0 deletions packages/core/test/textWrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'bun:test';
import { wrapTextWithWidth } from '../src/utils/textWrap';

describe('wrapTextWithWidth Unit Tests', () => {
it('should wrap basic ASCII text without breaking words unnecessarily', () => {
const lines = wrapTextWithWidth('Hello World Test', 12, { trim: true });
expect(lines.length).toBeGreaterThan(1);
expect(lines[0]).toBe('Hello World');
});

it('should preserve multi-byte emoji boundaries when wrapping', () => {
const text = '🚀🎉⭐🔥💡';
const lines = wrapTextWithWidth(text, 4);
// Each emoji has a visual width of 2 columns. Max width 4 holds 2 emojis per line.
expect(lines.length).toBe(3);
expect(lines[0]).toBe('🚀🎉');
expect(lines[1]).toBe('⭐🔥');
expect(lines[2]).toBe('💡');
});

it('should handle newline characters properly', () => {
const text = 'Line 1\nLine 2';
const lines = wrapTextWithWidth(text, 20);
expect(lines).toEqual(['Line 1', 'Line 2']);
});

it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
});
Comment on lines +27 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test title promises negative-width coverage; only zero is asserted.

The test name states "should handle zero or negative maxWidth gracefully," but line 28 only exercises maxWidth = 0. There is no assertion for a negative maxWidth (for example -5). Add that case so the test matches its stated intent.

✅ Suggested addition
   it('should handle zero or negative maxWidth gracefully', () => {
     expect(wrapTextWithWidth('Test', 0)).toEqual([]);
     expect(wrapTextWithWidth('', 10)).toEqual([]);
+    expect(wrapTextWithWidth('Test', -5)).toEqual([]);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
});
it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
expect(wrapTextWithWidth('Test', -5)).toEqual([]);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/textWrap.test.ts` around lines 27 - 30, Update the test
“should handle zero or negative maxWidth gracefully” to add an assertion calling
wrapTextWithWidth with a negative maxWidth such as -5 and expecting an empty
array, while preserving the existing zero-width and empty-input assertions.

});
Loading