/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { Colors } from '../colors.js'; import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js'; import { theme } from '../semantic-colors.js'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { parseInputForHighlighting, parseSegmentsFromTokens, } from '../utils/highlight.js'; import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import type { Suggestion } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { computeGhostText } from './inputPromptText.js'; import type { TextBuffer } from './shared/text-buffer.js'; import type { DOMElement } from 'ink'; import { Box, Text } from 'ink'; import chalk from 'chalk'; import type React from 'react'; import { getBorderStyle } from '../contexts/UnicodeRenderingContext.js'; // --------------------------------------------------------------------------- // Render helpers // --------------------------------------------------------------------------- /** Build a highlighted cursor character if it falls within this segment. */ const applyCursorHighlight = ( display: string, isOnCursorLine: boolean, segLen: number, charCount: number, cursorVisualColAbsolute: number, ): { display: string; newCharCount: number } => { if (!isOnCursorLine || segLen === 0) { return { display, newCharCount: charCount + segLen }; } const segStart = charCount; const segEnd = segStart + segLen; if (cursorVisualColAbsolute >= segStart && cursorVisualColAbsolute < segEnd) { const charToHighlight = cpSlice( display, cursorVisualColAbsolute - segStart, cursorVisualColAbsolute - segStart + 1, ); const highlighted = charToHighlight ? chalk.inverse(charToHighlight) : charToHighlight; const newDisplay = cpSlice(display, 0, cursorVisualColAbsolute - segStart) + highlighted + cpSlice(display, cursorVisualColAbsolute - segStart + 1); return { display: newDisplay, newCharCount: segEnd }; } return { display, newCharCount: segEnd }; }; /** Render highlighted segments for a single visual line. */ const renderSegments = ( segments: ReadonlyArray<{ text: string; type: string }>, isOnCursorLine: boolean, cursorVisualColAbsolute: number, ): React.ReactNode[] => { const renderedLine: React.ReactNode[] = []; let charCount = 0; segments.forEach((seg, segIdx) => { const segLen = cpLen(seg.text); let display = seg.text; const result = applyCursorHighlight( display, isOnCursorLine, segLen, charCount, cursorVisualColAbsolute, ); display = result.display; charCount = result.newCharCount; const color = seg.type === 'command' || seg.type === 'file' ? theme.text.accent : undefined; if (segLen > 0) { renderedLine.push( {display} , ); } }); return renderedLine; }; /** Render a single visual line with syntax highlighting and cursor. */ const renderVisualLine = ( lineText: string, visualIdxInRenderedSet: number, scrollVisualRow: number, cursorVisualRowAbsolute: number, cursorVisualColAbsolute: number, buffer: TextBuffer, focus: boolean, inlineGhost: string, ): React.ReactNode => { const absoluteVisualIdx = scrollVisualRow + visualIdxInRenderedSet; const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; const isOnCursorLine = focus && visualIdxInRenderedSet === cursorVisualRow; const [logicalLineIdx] = mapEntry; const logicalLine = buffer.lines[logicalLineIdx] ?? ''; const transformations = buffer.transformationsByLine[logicalLineIdx] ?? []; const tokens = parseInputForHighlighting( logicalLine, logicalLineIdx, transformations, ...(focus && buffer.cursor[0] === logicalLineIdx ? [buffer.cursor[1]] : []), ); const startColInTransformed = buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0; const visualEndCol = startColInTransformed + cpLen(lineText); const segments = parseSegmentsFromTokens( tokens, startColInTransformed, visualEndCol, ); const renderedLine = renderSegments( segments, isOnCursorLine, cursorVisualColAbsolute, ); const currentLineGhost = isOnCursorLine ? inlineGhost : ''; if ( isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText) && !currentLineGhost ) { renderedLine.push( {chalk.inverse(' ')} , ); } const showCursorBeforeGhost = focus === true && isOnCursorLine === true && cursorVisualColAbsolute === cpLen(lineText) && currentLineGhost !== ''; if (!currentLineGhost && renderedLine.length === 0) { renderedLine.push( {' '} , ); } return ( {renderedLine} {showCursorBeforeGhost === true && chalk.inverse(' ')} {currentLineGhost !== '' && ( {currentLineGhost} )} ); }; /** Suggestion nodes for completion, reverse search, and shell path. */ export const useSuggestionsNodes = ( completion: UseCommandCompletionReturn, shellModeActive: boolean, reverseSearchActive: boolean, reverseSearchCompletion: { suggestions: Suggestion[]; activeSuggestionIndex: number; isLoadingSuggestions: boolean; visibleStartIndex: number; }, shellPathCompletion: { showSuggestions: boolean; suggestions: Suggestion[]; activeSuggestionIndex: number; isLoadingSuggestions: boolean; visibleStartIndex: number; }, suggestionsWidth: number, bufferText: string, ) => { const completionSuggestionsNode = completion.showSuggestions && !shellModeActive ? ( ) : null; const reverseSearchSuggestionsNode = reverseSearchActive ? ( ) : null; const shellPathSuggestionsNode = shellPathCompletion.showSuggestions ? ( ) : null; const suggestionsNode = completionSuggestionsNode ?? shellPathSuggestionsNode ?? reverseSearchSuggestionsNode; return suggestionsNode; }; /** Render ghost-line padding nodes for additional ghost text lines. */ const renderGhostLines = ( additionalLines: string[], inputWidth: number, ): React.ReactNode[] => additionalLines.map((ghostLine, index) => { const padding = Math.max(0, inputWidth - getCachedStringWidth(ghostLine)); return ( {ghostLine} {' '.repeat(padding)} ); }); // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- type PromptInputBoxProps = { buffer: TextBuffer; placeholder: string; focus: boolean; shellModeActive: boolean; reverseSearchActive: boolean; inputWidth: number; inlineGhost: string; additionalLines: string[]; /** * Ref to the text area itself, used to translate a terminal mouse click into * a buffer position. Attached to the inner box so the coordinates exclude the * border and prompt prefix. */ innerBoxRef?: React.RefObject; }; export const useGhostTextLines = ( completion: UseCommandCompletionReturn, buffer: TextBuffer, inputWidth: number, ): { inlineGhost: string; additionalLines: string[] } => { if ( completion.promptCompletion.text === '' || buffer.text === '' || !completion.promptCompletion.text.startsWith(buffer.text) ) { return { inlineGhost: '', additionalLines: [] }; } const ghostSuffix = completion.promptCompletion.text.slice( buffer.text.length, ); if (ghostSuffix === '') { return { inlineGhost: '', additionalLines: [] }; } return computeGhostText( ghostSuffix, buffer.cursor[1], buffer.lines[buffer.cursor[0]] ?? '', inputWidth, ); }; const renderPromptPrefixSymbol = ( shellModeActive: boolean, reverseSearchActive: boolean, ): React.ReactNode => { if (!shellModeActive) { return '> '; } if (reverseSearchActive) { return ( (r:){' '} ); } return '! '; }; const renderPromptPrefix = ( shellModeActive: boolean, reverseSearchActive: boolean, ): React.ReactNode => ( {renderPromptPrefixSymbol(shellModeActive, reverseSearchActive)} ); const renderPlaceholder = ( placeholder: string, focus: boolean, ): React.ReactNode => focus ? ( {chalk.inverse(placeholder.slice(0, 1))} {placeholder.slice(1)} ) : ( {placeholder} ); const renderInputLines = ( buffer: TextBuffer, focus: boolean, inputWidth: number, inlineGhost: string, additionalLines: string[], ): React.ReactNode[] => { const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = buffer.visualCursor; const scrollVisualRow = buffer.visualScrollRow; return buffer.viewportVisualLines .map((lineText, visualIdxInRenderedSet) => renderVisualLine( lineText, visualIdxInRenderedSet, scrollVisualRow, cursorVisualRowAbsolute, cursorVisualColAbsolute, buffer, focus, inlineGhost, ), ) .concat(renderGhostLines(additionalLines, inputWidth)); }; export const PromptInputBox: React.FC = ({ buffer, placeholder, focus, shellModeActive, reverseSearchActive, inputWidth, inlineGhost, additionalLines, innerBoxRef, }) => ( {renderPromptPrefix(shellModeActive, reverseSearchActive)} {buffer.text.length === 0 && placeholder ? renderPlaceholder(placeholder, focus) : renderInputLines( buffer, focus, inputWidth, inlineGhost, additionalLines, )} );