/**
* @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,
)}
);