mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 21:32:56 -07:00
feat: implement /rewind command (#15720)
This commit is contained in:
@@ -2007,7 +2007,9 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||
});
|
||||
unmount();
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
|
||||
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
parseSegmentsFromTokens,
|
||||
@@ -516,18 +516,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
} else {
|
||||
// Second ESC
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
} else {
|
||||
if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Second ESC
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
return;
|
||||
}
|
||||
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -252,17 +252,16 @@ describe('RewindViewer', () => {
|
||||
it.each([
|
||||
{
|
||||
description: 'removes reference markers',
|
||||
prompt:
|
||||
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
|
||||
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
|
||||
},
|
||||
{
|
||||
description: 'strips expanded MCP resource content',
|
||||
prompt:
|
||||
'read @server3:mcp://demo-resource hello\n' +
|
||||
'--- Content from referenced files ---\n' +
|
||||
`--- Content from referenced files ---\n` +
|
||||
'\nContent from @server3:mcp://demo-resource:\n' +
|
||||
'This is the content of the demo resource.\n' +
|
||||
'--- End of content ---',
|
||||
`--- End of content ---`,
|
||||
},
|
||||
])('$description', async ({ prompt }) => {
|
||||
const conversation = createConversation([
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
@@ -19,8 +19,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useRewind } from '../hooks/useRewind.js';
|
||||
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
||||
import { stripReferenceContent } from '../utils/formatters.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { ExpandableText } from './shared/ExpandableText.js';
|
||||
|
||||
interface RewindViewerProps {
|
||||
conversation: ConversationRecord;
|
||||
@@ -29,7 +30,7 @@ interface RewindViewerProps {
|
||||
messageId: string,
|
||||
newText: string,
|
||||
outcome: RewindOutcome,
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_LINES_PER_BOX = 2;
|
||||
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
onExit,
|
||||
onRewind,
|
||||
}) => {
|
||||
const [isRewinding, setIsRewinding] = useState(false);
|
||||
const { terminalWidth, terminalHeight } = useUIState();
|
||||
const {
|
||||
selectedMessageId,
|
||||
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
clearSelection,
|
||||
} = useRewind(conversation);
|
||||
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const interactions = useMemo(
|
||||
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
||||
[conversation.messages],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
interactions
|
||||
.map((msg, idx) => ({
|
||||
key: `${msg.id || 'msg'}-${idx}`,
|
||||
value: msg,
|
||||
index: idx,
|
||||
}))
|
||||
.reverse(),
|
||||
[interactions],
|
||||
);
|
||||
const items = useMemo(() => {
|
||||
const interactionItems = interactions.map((msg, idx) => ({
|
||||
key: `${msg.id || 'msg'}-${idx}`,
|
||||
value: msg,
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
// Add "Current Position" as the last item
|
||||
return [
|
||||
...interactionItems,
|
||||
{
|
||||
key: 'current-position',
|
||||
value: {
|
||||
id: 'current-position',
|
||||
type: 'user',
|
||||
content: 'Stay at current position',
|
||||
timestamp: new Date().toISOString(),
|
||||
} as MessageRecord,
|
||||
index: interactionItems.length,
|
||||
},
|
||||
];
|
||||
}, [interactions]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!selectedMessageId) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (
|
||||
highlightedMessageId &&
|
||||
highlightedMessageId !== 'current-position'
|
||||
) {
|
||||
setExpandedMessageId(highlightedMessageId);
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
setExpandedMessageId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||
|
||||
if (selectedMessageId) {
|
||||
if (isRewinding) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box>
|
||||
<CliSpinner />
|
||||
</Box>
|
||||
<Text>Rewinding...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedMessageId === 'current-position') {
|
||||
onExit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedMessage = interactions.find(
|
||||
(m) => m.id === selectedMessageId,
|
||||
);
|
||||
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
stats={confirmationStats}
|
||||
terminalWidth={terminalWidth}
|
||||
timestamp={selectedMessage?.timestamp}
|
||||
onConfirm={(outcome) => {
|
||||
onConfirm={async (outcome) => {
|
||||
if (outcome === RewindOutcome.Cancel) {
|
||||
clearSelection();
|
||||
} else {
|
||||
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
onRewind(selectedMessageId, cleanedText, outcome);
|
||||
setIsRewinding(true);
|
||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -138,12 +193,41 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
onSelect={(item: MessageRecord) => {
|
||||
const userPrompt = item;
|
||||
if (userPrompt && userPrompt.id) {
|
||||
selectMessage(userPrompt.id);
|
||||
if (userPrompt.id === 'current-position') {
|
||||
onExit();
|
||||
} else {
|
||||
selectMessage(userPrompt.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onHighlight={(item: MessageRecord) => {
|
||||
if (item.id) {
|
||||
setHighlightedMessageId(item.id);
|
||||
// Collapse when moving selection
|
||||
setExpandedMessageId(null);
|
||||
}
|
||||
}}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(itemWrapper, { isSelected }) => {
|
||||
const userPrompt = itemWrapper.value;
|
||||
|
||||
if (userPrompt.id === 'current-position') {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
>
|
||||
{partToString(userPrompt.content)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Cancel rewind and stay here
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = getStats(userPrompt);
|
||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||
const originalUserText = userPrompt.content
|
||||
@@ -154,25 +238,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<MaxSizedBox
|
||||
maxWidth={terminalWidth - 4}
|
||||
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{cleanedText.split('\n').map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Text
|
||||
color={
|
||||
isSelected
|
||||
? theme.status.success
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</MaxSizedBox>
|
||||
<ExpandableText
|
||||
label={cleanedText}
|
||||
isExpanded={expandedMessageId === userPrompt.id}
|
||||
textColor={
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
|
||||
maxLines={MAX_LINES_PER_BOX}
|
||||
/>
|
||||
</Box>
|
||||
{stats ? (
|
||||
<Box flexDirection="row">
|
||||
@@ -203,7 +277,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select a message, Esc to close)
|
||||
(Use Enter to select a message, Esc to close, Right/Left to
|
||||
expand/collapse)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import { Colors } from '../colors.js';
|
||||
export interface Suggestion {
|
||||
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
|
||||
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||
const labelElement = (
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={suggestion.value}
|
||||
matchedIndex={suggestion.matchedIndex}
|
||||
userInput={userInput}
|
||||
|
||||
@@ -8,8 +8,11 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
|
||||
│ ● some command @file │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -22,8 +25,11 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
|
||||
│ ● read @server3:mcp://demo-resource hello │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q1 │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q1 │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● Q3 │
|
||||
│ ● Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q1 │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Q3 │
|
||||
│ Q1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Q2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Q1 │
|
||||
│ Q3 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -146,8 +164,11 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
|
||||
│ ● Hello │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -158,16 +179,14 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● 1 │
|
||||
│ 2 │
|
||||
│ 3 │
|
||||
│ 4 │
|
||||
│ 5 │
|
||||
│ 6 │
|
||||
│ 7 │
|
||||
│ 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● Message 2 │
|
||||
│ ● Message 1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Message 1 │
|
||||
│ Message 2 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -208,8 +233,11 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
||||
│ ● Message 1 │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ ... last 5 lines hidden ... │
|
||||
│ Line A │
|
||||
│ Line B... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Line A │
|
||||
│ Line B │
|
||||
│ Line C │
|
||||
│ Line D │
|
||||
│ Line E │
|
||||
│ Line F │
|
||||
│ Line G │
|
||||
│ ● Line 1 │
|
||||
│ Line 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ ● Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ ● Line A │
|
||||
│ Line B... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Line A │
|
||||
│ Line B │
|
||||
│ ... last 5 lines hidden ... │
|
||||
│ Line 1 │
|
||||
│ Line 2... │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close) │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
+30
-10
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||
|
||||
describe('PrepareLabel', () => {
|
||||
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(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label="simple command"
|
||||
userInput=""
|
||||
matchedIndex={undefined}
|
||||
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
|
||||
it('truncates long label when collapsed and no match', () => {
|
||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
|
||||
it('shows full long label when expanded and no match', () => {
|
||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
|
||||
const userInput = 'commit';
|
||||
const matchedIndex = label.indexOf(userInput);
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={userInput}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
<PrepareLabel
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+39
-19
@@ -1,29 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
|
||||
export const MAX_WIDTH = 150;
|
||||
|
||||
export interface PrepareLabelProps {
|
||||
export interface ExpandableTextProps {
|
||||
label: string;
|
||||
matchedIndex?: number;
|
||||
userInput: string;
|
||||
textColor: string;
|
||||
userInput?: string;
|
||||
textColor?: string;
|
||||
isExpanded?: boolean;
|
||||
maxWidth?: number;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
const _ExpandableText: React.FC<ExpandableTextProps> = ({
|
||||
label,
|
||||
matchedIndex,
|
||||
userInput,
|
||||
textColor,
|
||||
userInput = '',
|
||||
textColor = theme.text.primary,
|
||||
isExpanded = false,
|
||||
maxWidth = MAX_WIDTH,
|
||||
maxLines,
|
||||
}) => {
|
||||
const hasMatch =
|
||||
matchedIndex !== undefined &&
|
||||
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
|
||||
// Render the plain label if there's no match
|
||||
if (!hasMatch) {
|
||||
const display = isExpanded
|
||||
? label
|
||||
: label.length > MAX_WIDTH
|
||||
? label.slice(0, MAX_WIDTH) + '...'
|
||||
: label;
|
||||
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}
|
||||
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
let after = '';
|
||||
|
||||
// Case 1: Show the full string if it's expanded or already fits
|
||||
if (isExpanded || label.length <= MAX_WIDTH) {
|
||||
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 >= MAX_WIDTH) {
|
||||
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
|
||||
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 = MAX_WIDTH - matchLength;
|
||||
const contextSpace = maxWidth - matchLength;
|
||||
const beforeSpace = Math.floor(contextSpace / 2);
|
||||
const afterSpace = Math.ceil(contextSpace / 2);
|
||||
|
||||
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const PrepareLabel = React.memo(_PrepareLabel);
|
||||
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