Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -7,8 +7,8 @@
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
@@ -23,6 +23,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
} from '../utils/highlight.js';
import {
clipboardHasImage,
saveClipboardImage,
@@ -30,7 +35,7 @@ import {
} from '../utils/clipboardUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -45,10 +50,37 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
@@ -63,13 +95,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
approvalMode,
onEscapePromptChange,
vimHandleInput,
isEmbeddedShellFocused,
}) => {
const isShellFocused = useShellFocusState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -81,12 +118,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
const historyData = shellHistory.history;
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
useState<number>(-1);
const shellHistory = useShellHistory(config.getProjectRoot());
const shellHistoryData = shellHistory.history;
const completion = useCommandCompletion(
buffer,
@@ -100,12 +140,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
shellHistoryData,
reverseSearchActive,
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@@ -129,6 +180,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
if (pasteTimeoutRef.current) {
clearTimeout(pasteTimeoutRef.current);
}
},
[],
);
@@ -178,6 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
resetCommandSearchCompletionState();
setExpandedSuggestionIndex(-1);
setJustNavigatedHistory(false);
}
}, [
@@ -186,6 +242,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
resetCommandSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -238,12 +295,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
}
if (key.paste) {
// Record paste time to prevent accidental auto-submission
setRecentPasteTime(Date.now());
// Clear any existing paste timeout
if (pasteTimeoutRef.current) {
clearTimeout(pasteTimeoutRef.current);
}
// Clear the paste protection after a safe delay
pasteTimeoutRef.current = setTimeout(() => {
setRecentPasteTime(null);
pasteTimeoutRef.current = null;
}, 500);
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
return;
@@ -271,9 +345,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.ESCAPE](key)) {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
const cancelSearch = (
setActive: (active: boolean) => void,
resetCompletion: () => void,
) => {
setActive(false);
resetCompletion();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
@@ -281,8 +358,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
cursorPosition[1],
);
buffer.moveToOffset(offset);
setExpandedSuggestionIndex(-1);
};
if (reverseSearchActive) {
cancelSearch(
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
@@ -291,6 +384,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
}
@@ -329,14 +423,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (reverseSearchActive) {
if (reverseSearchActive || commandSearchActive) {
const isCommandSearch = commandSearchActive;
const sc = isCommandSearch
? commandSearchCompletion
: reverseSearchCompletion;
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
} = sc;
const setActive = isCommandSearch
? setCommandSearchActive
: setReverseSearchActive;
const resetState = sc.resetCompletionState;
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
@@ -347,10 +451,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
navigateDown();
return;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
}
}
@@ -361,8 +477,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
resetState();
setActive(false);
return;
}
@@ -385,10 +501,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
}
}
@@ -401,6 +519,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
completion.handleAutocomplete(targetIndex);
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
@@ -418,6 +537,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
@@ -459,6 +585,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission
if (recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
}
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
@@ -506,6 +638,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
@@ -530,6 +667,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
!key.meta
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
},
[
@@ -552,12 +690,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
],
);
useKeypress(handleInput, {
isActive: true,
});
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -676,18 +815,47 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
return completion;
};
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
const showAutoAcceptStyling =
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
const showYoloStyling =
!shellModeActive && approvalMode === ApprovalMode.YOLO;
let statusColor: string | undefined;
let statusText = '';
if (shellModeActive) {
statusColor = theme.ui.symbol;
statusText = 'Shell mode';
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusText = 'YOLO mode';
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusText = 'Accepting edits';
}
return (
<>
<Box
borderStyle="round"
borderColor={
shellModeActive ? theme.status.warning : theme.border.focused
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
paddingX={1}
>
<Text
color={shellModeActive ? theme.status.warning : theme.text.accent}
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
@@ -698,15 +866,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(r:){' '}
</Text>
) : (
'! '
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'> '
)}
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
focus ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
@@ -717,70 +889,113 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) : (
linesToRender
.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
const ghostWidth = stringWidth(currentLineGhost);
const renderedLine: React.ReactNode[] = [];
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display)
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
if (!currentLineGhost) {
display = display + chalk.inverse(' ');
}
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
}
const showCursorBeforeGhost =
focus &&
visualIdxInRenderedSet === cursorVisualRow &&
cursorVisualColAbsolute ===
// eslint-disable-next-line no-control-regex
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
const actualDisplayWidth = stringWidth(display);
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
const totalContentWidth =
actualDisplayWidth + cursorWidth + ghostWidth;
const trailingPadding = Math.max(
0,
inputWidth - totalContentWidth,
);
return (
<Text key={`line-${visualIdxInRenderedSet}`}>
{display}
{showCursorBeforeGhost && chalk.inverse(' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
</Text>
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
);
})
.concat(
@@ -803,27 +1018,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
)}
</Box>
</Box>
{completion.showSuggestions && (
{shouldShowSuggestions && (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
isLoading={completion.isLoadingSuggestions}
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}
isLoading={activeCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={completion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
{reverseSearchActive && (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
buffer.text.startsWith('/') &&
!reverseSearchActive &&
!commandSearchActive
? 'slash'
: 'reverse'
}
expandedIndex={expandedSuggestionIndex}
/>
</Box>
)}