diff --git a/src/commands.test.ts b/src/commands.test.ts new file mode 100644 index 00000000..3b485e0d --- /dev/null +++ b/src/commands.test.ts @@ -0,0 +1,30 @@ +import { formatDescriptionWithSource } from './commands.js' + +describe('formatDescriptionWithSource', () => { + test('returns empty text for prompt commands missing a description', () => { + const command = { + name: 'example', + type: 'prompt', + source: 'builtin', + description: undefined, + } as any + + expect(formatDescriptionWithSource(command)).toBe('') + }) + + test('formats plugin commands with missing description safely', () => { + const command = { + name: 'example', + type: 'prompt', + source: 'plugin', + description: undefined, + pluginInfo: { + pluginManifest: { + name: 'MyPlugin', + }, + }, + } as any + + expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ') + }) +}) diff --git a/src/commands.ts b/src/commands.ts index 6fb8c600..5c5f6a9b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -740,23 +740,23 @@ export function getCommand(commandName: string, commands: Command[]): Command { */ export function formatDescriptionWithSource(cmd: Command): string { if (cmd.type !== 'prompt') { - return cmd.description + return cmd.description ?? '' } if (cmd.kind === 'workflow') { - return `${cmd.description} (workflow)` + return `${cmd.description ?? ''} (workflow)` } if (cmd.source === 'plugin') { const pluginName = cmd.pluginInfo?.pluginManifest.name if (pluginName) { - return `(${pluginName}) ${cmd.description}` + return `(${pluginName}) ${cmd.description ?? ''}` } - return `${cmd.description} (plugin)` + return `${cmd.description ?? ''} (plugin)` } if (cmd.source === 'builtin' || cmd.source === 'mcp') { - return cmd.description + return cmd.description ?? '' } if (cmd.source === 'bundled') { diff --git a/src/utils/truncate.test.ts b/src/utils/truncate.test.ts new file mode 100644 index 00000000..ccf5ccb3 --- /dev/null +++ b/src/utils/truncate.test.ts @@ -0,0 +1,15 @@ +import { truncate, truncateToWidth, truncatePathMiddle } from './truncate.js' + +describe('truncate utilities', () => { + test('truncate returns empty string for undefined input', () => { + expect(truncate(undefined, 10)).toBe('') + }) + + test('truncateToWidth returns empty string for undefined input', () => { + expect(truncateToWidth(undefined, 5)).toBe('') + }) + + test('truncatePathMiddle returns empty string for undefined path', () => { + expect(truncatePathMiddle(undefined, 20)).toBe('') + }) +}) diff --git a/src/utils/truncate.ts b/src/utils/truncate.ts index c0a1d716..2e5eaeee 100644 --- a/src/utils/truncate.ts +++ b/src/utils/truncate.ts @@ -13,10 +13,11 @@ import { getGraphemeSegmenter } from './intl.js' * @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 { +export function truncatePathMiddle(path: string | undefined, maxLength: number): string { + const safePath = path ?? '' // No truncation needed - if (stringWidth(path) <= maxLength) { - return path + if (stringWidth(safePath) <= maxLength) { + return safePath } // Handle edge case of very small or non-positive maxLength @@ -26,14 +27,14 @@ export function truncatePathMiddle(path: string, maxLength: number): string { // Need at least room for "…" + something meaningful if (maxLength < 5) { - return truncateToWidth(path, maxLength) + return truncateToWidth(safePath, maxLength) } // Find the filename (last path segment) - const lastSlash = path.lastIndexOf('/') + const lastSlash = safePath.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 filename = lastSlash >= 0 ? safePath.slice(lastSlash) : safePath + const directory = lastSlash >= 0 ? safePath.slice(0, lastSlash) : '' const filenameWidth = stringWidth(filename) // If filename alone is too long, truncate from start @@ -60,12 +61,13 @@ export function truncatePathMiddle(path: string, maxLength: number): string { * 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 +export function truncateToWidth(text: string | undefined, maxWidth: number): string { + const safeText = text ?? '' + if (stringWidth(safeText) <= maxWidth) return safeText if (maxWidth <= 1) return '…' let width = 0 let result = '' - for (const { segment } of getGraphemeSegmenter().segment(text)) { + for (const { segment } of getGraphemeSegmenter().segment(safeText)) { const segWidth = stringWidth(segment) if (width + segWidth > maxWidth - 1) break result += segment @@ -79,10 +81,11 @@ export function truncateToWidth(text: string, maxWidth: number): string { * Prepends '…' when truncation occurs. * Width-aware and grapheme-safe. */ -export function truncateStartToWidth(text: string, maxWidth: number): string { - if (stringWidth(text) <= maxWidth) return text +export function truncateStartToWidth(text: string | undefined, maxWidth: number): string { + const safeText = text ?? '' + if (stringWidth(safeText) <= maxWidth) return safeText if (maxWidth <= 1) return '…' - const segments = [...getGraphemeSegmenter().segment(text)] + const segments = [...getGraphemeSegmenter().segment(safeText)] let width = 0 let startIdx = segments.length for (let i = segments.length - 1; i >= 0; i--) { @@ -106,14 +109,15 @@ export function truncateStartToWidth(text: string, maxWidth: number): string { * Width-aware and grapheme-safe. */ export function truncateToWidthNoEllipsis( - text: string, + text: string | undefined, maxWidth: number, ): string { - if (stringWidth(text) <= maxWidth) return text + const safeText = text ?? '' + if (stringWidth(safeText) <= maxWidth) return safeText if (maxWidth <= 0) return '' let width = 0 let result = '' - for (const { segment } of getGraphemeSegmenter().segment(text)) { + for (const { segment } of getGraphemeSegmenter().segment(safeText)) { const segWidth = stringWidth(segment) if (width + segWidth > maxWidth) break result += segment @@ -133,20 +137,19 @@ export function truncateToWidthNoEllipsis( */ export function truncate( - str: string, + str: string | undefined, maxWidth: number, singleLine: boolean = false, ): string { - // Undefined or null protection - if (!str) return '' - - let result = str + const safeStr = str ?? '' + if (safeStr === '') return '' + let result = safeStr // If singleLine is true, truncate at first newline if (singleLine) { - const firstNewline = str.indexOf('\n') + const firstNewline = safeStr.indexOf('\n') if (firstNewline !== -1) { - result = str.substring(0, firstNewline) + result = safeStr.substring(0, firstNewline) // Ensure total width including ellipsis doesn't exceed maxWidth if (stringWidth(result) + 1 > maxWidth) { return truncateToWidth(result, maxWidth)