import stringWidth from 'string-width'; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); /** The number of terminal cells occupied by a string. */ export function terminalCellWidth(value: string): number { return stringWidth(value); } /** * Truncate without splitting emoji, combining marks, or other grapheme * clusters. The ellipsis itself is included in the cell budget. */ export function truncateTerminalText(value: string, maxWidth: number): string { const width = Number.isFinite(maxWidth) ? Math.max(0, Math.floor(maxWidth)) : 0; if (width === 0 || value.length === 0) return ''; if (terminalCellWidth(value) <= width) return value; const ellipsis = '…'; if (width <= terminalCellWidth(ellipsis)) return ellipsis; const budget = width - terminalCellWidth(ellipsis); let used = 0; let result = ''; for (const { segment } of graphemeSegmenter.segment(value)) { const segmentWidth = terminalCellWidth(segment); if (used + segmentWidth > budget) break; result += segment; used += segmentWidth; } return `${result}${ellipsis}`; } /** Truncate and right-pad a string to an exact terminal-cell width. */ export function fitTerminalText(value: string, width: number): string { const target = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0; const truncated = truncateTerminalText(value, target); return `${truncated}${' '.repeat(Math.max(0, target - terminalCellWidth(truncated)))}`; } /** Remove one user-perceived character without splitting an emoji cluster. */ export function removeLastGrapheme(value: string): string { const segments = Array.from(graphemeSegmenter.segment(value)); const last = segments.at(-1); return last ? value.slice(0, last.index) : ''; } /** Keep at most `limit` grapheme clusters without adding display punctuation. */ export function limitGraphemes(value: string, limit: number): string { const maximum = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0; if (maximum === 0) return ''; let result = ''; let count = 0; for (const { segment } of graphemeSegmenter.segment(value)) { if (count >= maximum) break; result += segment; count++; } return result; }