import * as React from 'react'
import { memo, type ReactNode } from 'react'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { stringWidth } from '../../ink/stringWidth.js'
import { Box, Text } from '../../ink.js'
import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js'
import type { Theme } from '../../utils/theme.js'
export type SuggestionItem = {
id: string
displayText: string
tag?: string
description?: string
metadata?: unknown
color?: keyof Theme
}
export type SuggestionType =
| 'command'
| 'file'
| 'directory'
| 'agent'
| 'shell'
| 'custom-title'
| 'slack-channel'
| 'none'
export const OVERLAY_MAX_ITEMS = 5
/**
* Get the icon for a suggestion based on its type
* Icons: + for files, ◇ for MCP resources, * for agents
*/
function getIcon(itemId: string): string {
if (itemId.startsWith('file-')) return '+'
if (itemId.startsWith('mcp-resource-')) return '◇'
if (itemId.startsWith('agent-')) return '*'
return '+'
}
/**
* Check if an item is a unified suggestion type (file, mcp-resource, or agent)
*/
function isUnifiedSuggestion(itemId: string): boolean {
return (
itemId.startsWith('file-') ||
itemId.startsWith('mcp-resource-') ||
itemId.startsWith('agent-')
)
}
const SuggestionItemRow = memo(function SuggestionItemRow({
item,
maxColumnWidth,
isSelected,
}: {
item: SuggestionItem
maxColumnWidth?: number
isSelected: boolean
}): ReactNode {
const columns = useTerminalSize().columns
const isUnified = isUnifiedSuggestion(item.id)
// For unified suggestions (file, mcp-resource, agent), use single-line layout with icon
if (isUnified) {
const icon = getIcon(item.id)
const textColor: keyof Theme | undefined = isSelected
? 'suggestion'
: undefined
const dimColor = !isSelected
const isFile = item.id.startsWith('file-')
const isMcpResource = item.id.startsWith('mcp-resource-')
// Calculate layout widths
// Layout: "X " (2) + displayText + " – " (3) + description + padding (4)
const iconWidth = 2 // icon + space (fixed)
const paddingWidth = 4
const separatorWidth = item.description ? 3 : 0 // ' – ' separator
// For files, truncate middle of path to show both directory context and filename
// For MCP resources, limit displayText to 30 chars (truncate from end)
// For agents, no truncation
let displayText: string
if (isFile) {
// Reserve space for description if present, otherwise use all available space
const descReserve = item.description
? Math.min(20, stringWidth(item.description))
: 0
const maxPathLength =
columns - iconWidth - paddingWidth - separatorWidth - descReserve
displayText = truncatePathMiddle(item.displayText, maxPathLength)
} else if (isMcpResource) {
const maxDisplayTextLength = 30
displayText = truncateToWidth(item.displayText, maxDisplayTextLength)
} else {
displayText = item.displayText
}
const availableWidth =
columns -
iconWidth -
stringWidth(displayText) -
separatorWidth -
paddingWidth
// Build the full line as a single string to prevent wrapping
let lineContent: string
if (item.description) {
const maxDescLength = Math.max(0, availableWidth)
const truncatedDesc = truncateToWidth(
item.description.replace(/\s+/g, ' '),
maxDescLength,
)
lineContent = `${icon} ${displayText} – ${truncatedDesc}`
} else {
lineContent = `${icon} ${displayText}`
}
return (
{lineContent}
)
}
// For non-unified suggestions (commands, shell, etc.), use improved layout from main
// Cap the command name column at 40% of terminal width to ensure description has space
const maxNameWidth = Math.floor(columns * 0.4)
const displayTextWidth = Math.min(
maxColumnWidth ?? stringWidth(item.displayText) + 5,
maxNameWidth,
)
const textColor = item.color || (isSelected ? 'suggestion' : undefined)
const shouldDim = !isSelected
// Truncate and pad the display text to fixed width
let displayText = item.displayText
if (stringWidth(displayText) > displayTextWidth - 2) {
displayText = truncateToWidth(displayText, displayTextWidth - 2)
}
const paddedDisplayText =
displayText +
' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText)))
const tagText = item.tag ? `[${item.tag}] ` : ''
const tagWidth = stringWidth(tagText)
const descriptionWidth = Math.max(
0,
columns - displayTextWidth - tagWidth - 4,
)
// Skill descriptions can contain newlines (e.g. /claude-api's "TRIGGER
// when:" block). A multi-line row grows the overlay past minHeight; when
// the filter narrows past that skill, the overlay shrinks and leaves
// ghost rows. Flatten to one line before truncating.
const truncatedDescription = item.description
? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth)
: ''
return (
{paddedDisplayText}
{tagText ? {tagText} : null}
{truncatedDescription}
)
})
type Props = {
suggestions: SuggestionItem[]
selectedSuggestion: number
maxColumnWidth?: number
/**
* When true, the suggestions are rendered inside a position=absolute
* overlay. We omit minHeight and flex-end so the y-clamp in the
* renderer doesn't push fewer items down into the prompt area.
*/
overlay?: boolean
}
export function PromptInputFooterSuggestions({
suggestions,
selectedSuggestion,
maxColumnWidth: maxColumnWidthProp,
overlay,
}: Props): ReactNode {
const { rows } = useTerminalSize()
// Maximum number of suggestions to show at once (leaving space for prompt).
// Overlay mode (fullscreen) uses a fixed 5 — the floating box sits over
// the ScrollBox, so terminal height isn't the constraint.
const maxVisibleItems = overlay
? OVERLAY_MAX_ITEMS
: Math.min(6, Math.max(1, rows - 3))
// No suggestions to display
if (suggestions.length === 0) {
return null
}
// Use prop if provided (stable width from all commands), otherwise calculate from visible
const maxColumnWidth =
maxColumnWidthProp ??
Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5
// Calculate visible items range based on selected index
const startIndex = Math.max(
0,
Math.min(
selectedSuggestion - Math.floor(maxVisibleItems / 2),
suggestions.length - maxVisibleItems,
),
)
const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length)
const visibleItems = suggestions.slice(startIndex, endIndex)
// In non-overlay (inline) mode, justifyContent keeps suggestions
// anchored to the bottom (near the prompt). In overlay mode we omit
// both minHeight and flex-end: the parent is position=absolute with
// bottom='100%', so its y is clamped to 0 by the renderer when it
// would go negative. Adding minHeight + flex-end would create empty
// padding rows that shift the visible items down into the prompt area
// when the list has fewer items than maxVisibleItems.
return (
{visibleItems.map(item => (
))}
)
}
export default memo(PromptInputFooterSuggestions)