fix(help): prevent /help tab crash from undefined descriptions (#732)
- Guard formatDescriptionWithSource() so missing command descriptions become '' - Harden truncate helpers to accept undefined text/path safely - Add regression tests covering undefined input cases
This commit is contained in:
30
src/commands.test.ts
Normal file
30
src/commands.test.ts
Normal file
@@ -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) ')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -740,23 +740,23 @@ export function getCommand(commandName: string, commands: Command[]): Command {
|
|||||||
*/
|
*/
|
||||||
export function formatDescriptionWithSource(cmd: Command): string {
|
export function formatDescriptionWithSource(cmd: Command): string {
|
||||||
if (cmd.type !== 'prompt') {
|
if (cmd.type !== 'prompt') {
|
||||||
return cmd.description
|
return cmd.description ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.kind === 'workflow') {
|
if (cmd.kind === 'workflow') {
|
||||||
return `${cmd.description} (workflow)`
|
return `${cmd.description ?? ''} (workflow)`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.source === 'plugin') {
|
if (cmd.source === 'plugin') {
|
||||||
const pluginName = cmd.pluginInfo?.pluginManifest.name
|
const pluginName = cmd.pluginInfo?.pluginManifest.name
|
||||||
if (pluginName) {
|
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') {
|
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
|
||||||
return cmd.description
|
return cmd.description ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.source === 'bundled') {
|
if (cmd.source === 'bundled') {
|
||||||
|
|||||||
15
src/utils/truncate.test.ts
Normal file
15
src/utils/truncate.test.ts
Normal file
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,10 +13,11 @@ import { getGraphemeSegmenter } from './intl.js'
|
|||||||
* @param maxLength Maximum display width of the result in terminal columns (must be > 0)
|
* @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
|
* @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
|
// No truncation needed
|
||||||
if (stringWidth(path) <= maxLength) {
|
if (stringWidth(safePath) <= maxLength) {
|
||||||
return path
|
return safePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle edge case of very small or non-positive maxLength
|
// 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
|
// Need at least room for "…" + something meaningful
|
||||||
if (maxLength < 5) {
|
if (maxLength < 5) {
|
||||||
return truncateToWidth(path, maxLength)
|
return truncateToWidth(safePath, maxLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the filename (last path segment)
|
// Find the filename (last path segment)
|
||||||
const lastSlash = path.lastIndexOf('/')
|
const lastSlash = safePath.lastIndexOf('/')
|
||||||
// Include the leading slash in filename for display
|
// Include the leading slash in filename for display
|
||||||
const filename = lastSlash >= 0 ? path.slice(lastSlash) : path
|
const filename = lastSlash >= 0 ? safePath.slice(lastSlash) : safePath
|
||||||
const directory = lastSlash >= 0 ? path.slice(0, lastSlash) : ''
|
const directory = lastSlash >= 0 ? safePath.slice(0, lastSlash) : ''
|
||||||
const filenameWidth = stringWidth(filename)
|
const filenameWidth = stringWidth(filename)
|
||||||
|
|
||||||
// If filename alone is too long, truncate from start
|
// 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.
|
* Splits on grapheme boundaries to avoid breaking emoji or surrogate pairs.
|
||||||
* Appends '…' when truncation occurs.
|
* Appends '…' when truncation occurs.
|
||||||
*/
|
*/
|
||||||
export function truncateToWidth(text: string, maxWidth: number): string {
|
export function truncateToWidth(text: string | undefined, maxWidth: number): string {
|
||||||
if (stringWidth(text) <= maxWidth) return text
|
const safeText = text ?? ''
|
||||||
|
if (stringWidth(safeText) <= maxWidth) return safeText
|
||||||
if (maxWidth <= 1) return '…'
|
if (maxWidth <= 1) return '…'
|
||||||
let width = 0
|
let width = 0
|
||||||
let result = ''
|
let result = ''
|
||||||
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
for (const { segment } of getGraphemeSegmenter().segment(safeText)) {
|
||||||
const segWidth = stringWidth(segment)
|
const segWidth = stringWidth(segment)
|
||||||
if (width + segWidth > maxWidth - 1) break
|
if (width + segWidth > maxWidth - 1) break
|
||||||
result += segment
|
result += segment
|
||||||
@@ -79,10 +81,11 @@ export function truncateToWidth(text: string, maxWidth: number): string {
|
|||||||
* Prepends '…' when truncation occurs.
|
* Prepends '…' when truncation occurs.
|
||||||
* Width-aware and grapheme-safe.
|
* Width-aware and grapheme-safe.
|
||||||
*/
|
*/
|
||||||
export function truncateStartToWidth(text: string, maxWidth: number): string {
|
export function truncateStartToWidth(text: string | undefined, maxWidth: number): string {
|
||||||
if (stringWidth(text) <= maxWidth) return text
|
const safeText = text ?? ''
|
||||||
|
if (stringWidth(safeText) <= maxWidth) return safeText
|
||||||
if (maxWidth <= 1) return '…'
|
if (maxWidth <= 1) return '…'
|
||||||
const segments = [...getGraphemeSegmenter().segment(text)]
|
const segments = [...getGraphemeSegmenter().segment(safeText)]
|
||||||
let width = 0
|
let width = 0
|
||||||
let startIdx = segments.length
|
let startIdx = segments.length
|
||||||
for (let i = segments.length - 1; i >= 0; i--) {
|
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.
|
* Width-aware and grapheme-safe.
|
||||||
*/
|
*/
|
||||||
export function truncateToWidthNoEllipsis(
|
export function truncateToWidthNoEllipsis(
|
||||||
text: string,
|
text: string | undefined,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
): string {
|
): string {
|
||||||
if (stringWidth(text) <= maxWidth) return text
|
const safeText = text ?? ''
|
||||||
|
if (stringWidth(safeText) <= maxWidth) return safeText
|
||||||
if (maxWidth <= 0) return ''
|
if (maxWidth <= 0) return ''
|
||||||
let width = 0
|
let width = 0
|
||||||
let result = ''
|
let result = ''
|
||||||
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
for (const { segment } of getGraphemeSegmenter().segment(safeText)) {
|
||||||
const segWidth = stringWidth(segment)
|
const segWidth = stringWidth(segment)
|
||||||
if (width + segWidth > maxWidth) break
|
if (width + segWidth > maxWidth) break
|
||||||
result += segment
|
result += segment
|
||||||
@@ -133,20 +137,19 @@ export function truncateToWidthNoEllipsis(
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export function truncate(
|
export function truncate(
|
||||||
str: string,
|
str: string | undefined,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
singleLine: boolean = false,
|
singleLine: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
// Undefined or null protection
|
const safeStr = str ?? ''
|
||||||
if (!str) return ''
|
if (safeStr === '') return ''
|
||||||
|
let result = safeStr
|
||||||
let result = str
|
|
||||||
|
|
||||||
// If singleLine is true, truncate at first newline
|
// If singleLine is true, truncate at first newline
|
||||||
if (singleLine) {
|
if (singleLine) {
|
||||||
const firstNewline = str.indexOf('\n')
|
const firstNewline = safeStr.indexOf('\n')
|
||||||
if (firstNewline !== -1) {
|
if (firstNewline !== -1) {
|
||||||
result = str.substring(0, firstNewline)
|
result = safeStr.substring(0, firstNewline)
|
||||||
// Ensure total width including ellipsis doesn't exceed maxWidth
|
// Ensure total width including ellipsis doesn't exceed maxWidth
|
||||||
if (stringWidth(result) + 1 > maxWidth) {
|
if (stringWidth(result) + 1 > maxWidth) {
|
||||||
return truncateToWidth(result, maxWidth)
|
return truncateToWidth(result, maxWidth)
|
||||||
|
|||||||
Reference in New Issue
Block a user