/ src / utils / truncate.ts
truncate.ts
  1  // Width-aware truncation/wrapping — needs ink/stringWidth (not leaf-safe).
  2  
  3  import { stringWidth } from '../ink/stringWidth.js'
  4  import { 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   */
 16  export 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   */
 63  export 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   */
 82  export 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   */
108  export 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   */
134  export 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  
160  export 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  }