File size: 3,643 Bytes
52efc7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../../semantic-colors.js';
export const MAX_WIDTH = 150;
export interface ExpandableTextProps {
label: string;
matchedIndex?: number;
userInput?: string;
textColor?: string;
isExpanded?: boolean;
maxWidth?: number;
maxLines?: number;
}
const _ExpandableText: React.FC<ExpandableTextProps> = ({
label,
matchedIndex,
userInput = '',
textColor = theme.text.primary,
isExpanded = false,
maxWidth = MAX_WIDTH,
maxLines,
}) => {
const hasMatch =
matchedIndex !== undefined &&
matchedIndex >= 0 &&
matchedIndex < label.length &&
userInput.length > 0;
// Render the plain label if there's no match
if (!hasMatch) {
let display = label;
if (!isExpanded) {
if (maxLines !== undefined) {
const lines = label.split('\n');
// 1. Truncate by logical lines
let truncated = lines.slice(0, maxLines).join('\n');
const hasMoreLines = lines.length > maxLines;
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
if (truncated.length > maxWidth) {
truncated = truncated.slice(0, maxWidth) + '...';
} else if (hasMoreLines) {
truncated += '...';
}
display = truncated;
} else if (label.length > maxWidth) {
display = label.slice(0, maxWidth) + '...';
}
}
return (
<Text wrap="wrap" color={textColor}>
{display}
</Text>
);
}
const matchLength = userInput.length;
let before = '';
let match = '';
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= maxWidth) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= maxWidth) {
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = maxWidth - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
let start = matchedIndex - beforeSpace;
let end = matchedIndex + matchLength + afterSpace;
if (start < 0) {
end += -start; // Slide window right
start = 0;
}
if (end > label.length) {
start -= end - label.length; // Slide window left
end = label.length;
}
start = Math.max(0, start);
const finalMatchIndex = matchedIndex - start;
const slicedLabel = label.slice(start, end);
before = slicedLabel.slice(0, finalMatchIndex);
match = slicedLabel.slice(finalMatchIndex, finalMatchIndex + matchLength);
after = slicedLabel.slice(finalMatchIndex + matchLength);
if (start > 0) {
before = before.length >= 3 ? '...' + before.slice(3) : '...';
}
if (end < label.length) {
after = after.length >= 3 ? after.slice(0, -3) + '...' : '...';
}
}
return (
<Text color={textColor} wrap="wrap">
{before}
{match
? match.split(/(\s+)/).map((part, index) => (
<Text key={`match-${index}`} inverse color={textColor}>
{part}
</Text>
))
: null}
{after}
</Text>
);
};
export const ExpandableText = React.memo(_ExpandableText);
|