source dump of claude code
at main 179 lines 5.7 kB view raw
1// Width-aware truncation/wrapping — needs ink/stringWidth (not leaf-safe). 2 3import { stringWidth } from '../ink/stringWidth.js' 4import { getGraphemeSegmenter } from './intl.js' 5 6/** 7 * Truncates a file path in the middle to preserve both directory context and filename. 8 * Width-aware: uses stringWidth() for correct CJK/emoji measurement. 9 * For example: "src/components/deeply/nested/folder/MyComponent.tsx" becomes 10 * "src/components/…/MyComponent.tsx" when maxLength is 30. 11 * 12 * @param path The file path to truncate 13 * @param maxLength Maximum display width of the result in terminal columns (must be > 0) 14 * @returns The truncated path, or original if it fits within maxLength 15 */ 16export function truncatePathMiddle(path: string, maxLength: number): string { 17 // No truncation needed 18 if (stringWidth(path) <= maxLength) { 19 return path 20 } 21 22 // Handle edge case of very small or non-positive maxLength 23 if (maxLength <= 0) { 24 return '…' 25 } 26 27 // Need at least room for "…" + something meaningful 28 if (maxLength < 5) { 29 return truncateToWidth(path, maxLength) 30 } 31 32 // Find the filename (last path segment) 33 const lastSlash = path.lastIndexOf('/') 34 // Include the leading slash in filename for display 35 const filename = lastSlash >= 0 ? path.slice(lastSlash) : path 36 const directory = lastSlash >= 0 ? path.slice(0, lastSlash) : '' 37 const filenameWidth = stringWidth(filename) 38 39 // If filename alone is too long, truncate from start 40 if (filenameWidth >= maxLength - 1) { 41 return truncateStartToWidth(path, maxLength) 42 } 43 44 // Calculate space available for directory prefix 45 // Result format: directory + "…" + filename 46 const availableForDir = maxLength - 1 - filenameWidth // -1 for ellipsis 47 48 if (availableForDir <= 0) { 49 // No room for directory, just show filename (truncated if needed) 50 return truncateStartToWidth(filename, maxLength) 51 } 52 53 // Truncate directory and combine 54 const truncatedDir = truncateToWidthNoEllipsis(directory, availableForDir) 55 return truncatedDir + '…' + filename 56} 57 58/** 59 * Truncates a string to fit within a maximum display width, measured in terminal columns. 60 * Splits on grapheme boundaries to avoid breaking emoji or surrogate pairs. 61 * Appends '…' when truncation occurs. 62 */ 63export function truncateToWidth(text: string, maxWidth: number): string { 64 if (stringWidth(text) <= maxWidth) return text 65 if (maxWidth <= 1) return '…' 66 let width = 0 67 let result = '' 68 for (const { segment } of getGraphemeSegmenter().segment(text)) { 69 const segWidth = stringWidth(segment) 70 if (width + segWidth > maxWidth - 1) break 71 result += segment 72 width += segWidth 73 } 74 return result + '…' 75} 76 77/** 78 * Truncates from the start of a string, keeping the tail end. 79 * Prepends '…' when truncation occurs. 80 * Width-aware and grapheme-safe. 81 */ 82export function truncateStartToWidth(text: string, maxWidth: number): string { 83 if (stringWidth(text) <= maxWidth) return text 84 if (maxWidth <= 1) return '…' 85 const segments = [...getGraphemeSegmenter().segment(text)] 86 let width = 0 87 let startIdx = segments.length 88 for (let i = segments.length - 1; i >= 0; i--) { 89 const segWidth = stringWidth(segments[i]!.segment) 90 if (width + segWidth > maxWidth - 1) break // -1 for '…' 91 width += segWidth 92 startIdx = i 93 } 94 return ( 95 '…' + 96 segments 97 .slice(startIdx) 98 .map(s => s.segment) 99 .join('') 100 ) 101} 102 103/** 104 * Truncates a string to fit within a maximum display width, without appending an ellipsis. 105 * Useful when the caller adds its own separator (e.g. middle-truncation with '…' between parts). 106 * Width-aware and grapheme-safe. 107 */ 108export function truncateToWidthNoEllipsis( 109 text: string, 110 maxWidth: number, 111): string { 112 if (stringWidth(text) <= maxWidth) return text 113 if (maxWidth <= 0) return '' 114 let width = 0 115 let result = '' 116 for (const { segment } of getGraphemeSegmenter().segment(text)) { 117 const segWidth = stringWidth(segment) 118 if (width + segWidth > maxWidth) break 119 result += segment 120 width += segWidth 121 } 122 return result 123} 124 125/** 126 * Truncates a string to fit within a maximum display width (terminal columns), 127 * splitting on grapheme boundaries to avoid breaking emoji, CJK, or surrogate pairs. 128 * Appends '…' when truncation occurs. 129 * @param str The string to truncate 130 * @param maxWidth Maximum display width in terminal columns 131 * @param singleLine If true, also truncates at the first newline 132 * @returns The truncated string with ellipsis if needed 133 */ 134export function truncate( 135 str: string, 136 maxWidth: number, 137 singleLine: boolean = false, 138): string { 139 let result = str 140 141 // If singleLine is true, truncate at first newline 142 if (singleLine) { 143 const firstNewline = str.indexOf('\n') 144 if (firstNewline !== -1) { 145 result = str.substring(0, firstNewline) 146 // Ensure total width including ellipsis doesn't exceed maxWidth 147 if (stringWidth(result) + 1 > maxWidth) { 148 return truncateToWidth(result, maxWidth) 149 } 150 return `${result}` 151 } 152 } 153 154 if (stringWidth(result) <= maxWidth) { 155 return result 156 } 157 return truncateToWidth(result, maxWidth) 158} 159 160export function wrapText(text: string, width: number): string[] { 161 const lines: string[] = [] 162 let currentLine = '' 163 let currentWidth = 0 164 165 for (const { segment } of getGraphemeSegmenter().segment(text)) { 166 const segWidth = stringWidth(segment) 167 if (currentWidth + segWidth <= width) { 168 currentLine += segment 169 currentWidth += segWidth 170 } else { 171 if (currentLine) lines.push(currentLine) 172 currentLine = segment 173 currentWidth = segWidth 174 } 175 } 176 177 if (currentLine) lines.push(currentLine) 178 return lines 179}