mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 16:10:59 -07:00
feat: implement /rewind command (#15720)
This commit is contained in:
151
packages/cli/src/ui/components/shared/ExpandableText.test.tsx
Normal file
151
packages/cli/src/ui/components/shared/ExpandableText.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||
|
||||
describe('ExpandableText', () => {
|
||||
const color = 'white';
|
||||
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
||||
|
||||
it('renders plain label when no match (short label)', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label="simple command"
|
||||
userInput=""
|
||||
matchedIndex={undefined}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates long label when collapsed and no match', () => {
|
||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(f.length).toBe(MAX_WIDTH + 3);
|
||||
expect(out).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows full long label when expanded and no match', () => {
|
||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.length).toBe(long.length);
|
||||
expect(out).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('highlights matched substring when expanded (text only visible)', () => {
|
||||
const label = 'run: git commit -m "feat: add search"';
|
||||
const userInput = 'commit';
|
||||
const matchedIndex = label.indexOf(userInput);
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={userInput}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
100,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('creates centered window around match when collapsed', () => {
|
||||
const prefix = 'cd_/very/long/path/that/keeps/going/'.repeat(3);
|
||||
const core = 'search-here';
|
||||
const suffix = '/and/then/some/more/components/'.repeat(3);
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
100,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes(core)).toBe(true);
|
||||
expect(f.startsWith('...')).toBe(true);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(out).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates match itself when match is very long', () => {
|
||||
const prefix = 'find ';
|
||||
const core = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const suffix = ' in this text';
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes('...')).toBe(true);
|
||||
expect(f.startsWith('...')).toBe(false);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(f.length).toBe(MAX_WIDTH + 2);
|
||||
expect(out).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('respects custom maxWidth', () => {
|
||||
const customWidth = 50;
|
||||
const long = 'z'.repeat(100);
|
||||
const { lastFrame, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
maxWidth={customWidth}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(f.length).toBe(customWidth + 3);
|
||||
expect(out).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
136
packages/cli/src/ui/components/shared/ExpandableText.tsx
Normal file
136
packages/cli/src/ui/components/shared/ExpandableText.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @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}`}
|
||||
color={theme.background.primary}
|
||||
backgroundColor={theme.text.primary}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExpandableText = React.memo(_ExpandableText);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
|
||||
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||
components//and/then/some/more/components//and/..."
|
||||
`;
|
||||
|
||||
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||
|
||||
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||
|
||||
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||
|
||||
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
|
||||
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||
`;
|
||||
|
||||
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
|
||||
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
@@ -0,0 +1,27 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
|
||||
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||
components//and/then/some/more/components//and/..."
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||
|
||||
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||
|
||||
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||
|
||||
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
|
||||
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
Reference in New Issue
Block a user