This commit fixes a crash in the CLI that occurs when navigating to the /help commands tab. The issue happens because the truncate function receives an undefined value for the str parameter if a command lacks a description, causing the .indexOf() method to throw an exception. To resolve this, an early return check was added at the beginning of the function to gracefully handle empty values and prevent the UI from crashing.
184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
// Width-aware truncation/wrapping — needs ink/stringWidth (not leaf-safe).
|
|
|
|
import { stringWidth } from '../ink/stringWidth.js'
|
|
import { getGraphemeSegmenter } from './intl.js'
|
|
|
|
/**
|
|
* Truncates a file path in the middle to preserve both directory context and filename.
|
|
* Width-aware: uses stringWidth() for correct CJK/emoji measurement.
|
|
* For example: "src/components/deeply/nested/folder/MyComponent.tsx" becomes
|
|
* "src/components/…/MyComponent.tsx" when maxLength is 30.
|
|
*
|
|
* @param path The file path to truncate
|
|
* @param maxLength Maximum display width of the result in terminal columns (must be > 0)
|
|
* @returns The truncated path, or original if it fits within maxLength
|
|
*/
|
|
export function truncatePathMiddle(path: string, maxLength: number): string {
|
|
// No truncation needed
|
|
if (stringWidth(path) <= maxLength) {
|
|
return path
|
|
}
|
|
|
|
// Handle edge case of very small or non-positive maxLength
|
|
if (maxLength <= 0) {
|
|
return '…'
|
|
}
|
|
|
|
// Need at least room for "…" + something meaningful
|
|
if (maxLength < 5) {
|
|
return truncateToWidth(path, maxLength)
|
|
}
|
|
|
|
// Find the filename (last path segment)
|
|
const lastSlash = path.lastIndexOf('/')
|
|
// Include the leading slash in filename for display
|
|
const filename = lastSlash >= 0 ? path.slice(lastSlash) : path
|
|
const directory = lastSlash >= 0 ? path.slice(0, lastSlash) : ''
|
|
const filenameWidth = stringWidth(filename)
|
|
|
|
// If filename alone is too long, truncate from start
|
|
if (filenameWidth >= maxLength - 1) {
|
|
return truncateStartToWidth(path, maxLength)
|
|
}
|
|
|
|
// Calculate space available for directory prefix
|
|
// Result format: directory + "…" + filename
|
|
const availableForDir = maxLength - 1 - filenameWidth // -1 for ellipsis
|
|
|
|
if (availableForDir <= 0) {
|
|
// No room for directory, just show filename (truncated if needed)
|
|
return truncateStartToWidth(filename, maxLength)
|
|
}
|
|
|
|
// Truncate directory and combine
|
|
const truncatedDir = truncateToWidthNoEllipsis(directory, availableForDir)
|
|
return truncatedDir + '…' + filename
|
|
}
|
|
|
|
/**
|
|
* Truncates a string to fit within a maximum display width, measured in terminal columns.
|
|
* Splits on grapheme boundaries to avoid breaking emoji or surrogate pairs.
|
|
* Appends '…' when truncation occurs.
|
|
*/
|
|
export function truncateToWidth(text: string, maxWidth: number): string {
|
|
if (stringWidth(text) <= maxWidth) return text
|
|
if (maxWidth <= 1) return '…'
|
|
let width = 0
|
|
let result = ''
|
|
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
|
const segWidth = stringWidth(segment)
|
|
if (width + segWidth > maxWidth - 1) break
|
|
result += segment
|
|
width += segWidth
|
|
}
|
|
return result + '…'
|
|
}
|
|
|
|
/**
|
|
* Truncates from the start of a string, keeping the tail end.
|
|
* Prepends '…' when truncation occurs.
|
|
* Width-aware and grapheme-safe.
|
|
*/
|
|
export function truncateStartToWidth(text: string, maxWidth: number): string {
|
|
if (stringWidth(text) <= maxWidth) return text
|
|
if (maxWidth <= 1) return '…'
|
|
const segments = [...getGraphemeSegmenter().segment(text)]
|
|
let width = 0
|
|
let startIdx = segments.length
|
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
const segWidth = stringWidth(segments[i]!.segment)
|
|
if (width + segWidth > maxWidth - 1) break // -1 for '…'
|
|
width += segWidth
|
|
startIdx = i
|
|
}
|
|
return (
|
|
'…' +
|
|
segments
|
|
.slice(startIdx)
|
|
.map(s => s.segment)
|
|
.join('')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Truncates a string to fit within a maximum display width, without appending an ellipsis.
|
|
* Useful when the caller adds its own separator (e.g. middle-truncation with '…' between parts).
|
|
* Width-aware and grapheme-safe.
|
|
*/
|
|
export function truncateToWidthNoEllipsis(
|
|
text: string,
|
|
maxWidth: number,
|
|
): string {
|
|
if (stringWidth(text) <= maxWidth) return text
|
|
if (maxWidth <= 0) return ''
|
|
let width = 0
|
|
let result = ''
|
|
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
|
const segWidth = stringWidth(segment)
|
|
if (width + segWidth > maxWidth) break
|
|
result += segment
|
|
width += segWidth
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Truncates a string to fit within a maximum display width (terminal columns),
|
|
* splitting on grapheme boundaries to avoid breaking emoji, CJK, or surrogate pairs.
|
|
* Appends '…' when truncation occurs.
|
|
* @param str The string to truncate
|
|
* @param maxWidth Maximum display width in terminal columns
|
|
* @param singleLine If true, also truncates at the first newline
|
|
* @returns The truncated string with ellipsis if needed
|
|
*/
|
|
|
|
export function truncate(
|
|
str: string,
|
|
maxWidth: number,
|
|
singleLine: boolean = false,
|
|
): string {
|
|
// Undefined or null protection
|
|
if (!str) return ''
|
|
|
|
let result = str
|
|
|
|
// If singleLine is true, truncate at first newline
|
|
if (singleLine) {
|
|
const firstNewline = str.indexOf('\n')
|
|
if (firstNewline !== -1) {
|
|
result = str.substring(0, firstNewline)
|
|
// Ensure total width including ellipsis doesn't exceed maxWidth
|
|
if (stringWidth(result) + 1 > maxWidth) {
|
|
return truncateToWidth(result, maxWidth)
|
|
}
|
|
return `${result}…`
|
|
}
|
|
}
|
|
|
|
if (stringWidth(result) <= maxWidth) {
|
|
return result
|
|
}
|
|
return truncateToWidth(result, maxWidth)
|
|
}
|
|
|
|
export function wrapText(text: string, width: number): string[] {
|
|
const lines: string[] = []
|
|
let currentLine = ''
|
|
let currentWidth = 0
|
|
|
|
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
|
const segWidth = stringWidth(segment)
|
|
if (currentWidth + segWidth <= width) {
|
|
currentLine += segment
|
|
currentWidth += segWidth
|
|
} else {
|
|
if (currentLine) lines.push(currentLine)
|
|
currentLine = segment
|
|
currentWidth = segWidth
|
|
}
|
|
}
|
|
|
|
if (currentLine) lines.push(currentLine)
|
|
return lines
|
|
}
|