mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 04:54:25 -07:00
1914 lines
60 KiB
TypeScript
1914 lines
60 KiB
TypeScript
/**
|
||
* @license
|
||
* Copyright 2025 Google LLC
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import type React from 'react';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useState,
|
||
useRef,
|
||
useMemo,
|
||
Fragment,
|
||
} from 'react';
|
||
import clipboardy from 'clipboardy';
|
||
import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||
import { theme } from '../semantic-colors.js';
|
||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||
import {
|
||
ScrollableList,
|
||
type ScrollableListRef,
|
||
} from './shared/ScrollableList.js';
|
||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||
import {
|
||
type TextBuffer,
|
||
logicalPosToOffset,
|
||
expandPastePlaceholders,
|
||
getTransformUnderCursor,
|
||
LARGE_PASTE_LINE_THRESHOLD,
|
||
LARGE_PASTE_CHAR_THRESHOLD,
|
||
} from './shared/text-buffer.js';
|
||
import {
|
||
cpSlice,
|
||
cpLen,
|
||
toCodePoints,
|
||
cpIndexToOffset,
|
||
} from '../utils/textUtils.js';
|
||
import chalk from 'chalk';
|
||
import stringWidth from 'string-width';
|
||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||
import {
|
||
useCommandCompletion,
|
||
CompletionMode,
|
||
} from '../hooks/useCommandCompletion.js';
|
||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||
import { Command } from '../key/keyMatchers.js';
|
||
import { formatCommand } from '../key/keybindingUtils.js';
|
||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||
import {
|
||
ApprovalMode,
|
||
coreEvents,
|
||
debugLogger,
|
||
type Config,
|
||
} from '@google/gemini-cli-core';
|
||
import { useVoiceMode } from '../hooks/useVoiceMode.js';
|
||
import {
|
||
parseInputForHighlighting,
|
||
parseSegmentsFromTokens,
|
||
} from '../utils/highlight.js';
|
||
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
|
||
import {
|
||
clipboardHasImage,
|
||
saveClipboardImage,
|
||
cleanupOldClipboardImages,
|
||
} from '../utils/clipboardUtils.js';
|
||
import {
|
||
isAutoExecutableCommand,
|
||
isSlashCommand,
|
||
} from '../utils/commandUtils.js';
|
||
import { parseSlashCommand } from '../../utils/commands.js';
|
||
import * as path from 'node:path';
|
||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||
import { useUIState } from '../contexts/UIStateContext.js';
|
||
import { useInputState } from '../contexts/InputContext.js';
|
||
import {
|
||
appEvents,
|
||
AppEvent,
|
||
TransientMessageType,
|
||
} from '../../utils/events.js';
|
||
import { useSettings } from '../contexts/SettingsContext.js';
|
||
import { StreamingState } from '../types.js';
|
||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
|
||
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||
|
||
/**
|
||
* Returns if the terminal can be trusted to handle paste events atomically
|
||
* rather than potentially sending multiple paste events separated by line
|
||
* breaks which could trigger unintended command execution.
|
||
*/
|
||
export function isTerminalPasteTrusted(
|
||
kittyProtocolSupported: boolean,
|
||
): boolean {
|
||
// Ideally we could trust all VSCode family terminals as well but it appears
|
||
// we cannot as Cursor users on windows reported being impacted by this
|
||
// issue (https://github.com/google-gemini/gemini-cli/issues/3763).
|
||
return kittyProtocolSupported;
|
||
}
|
||
|
||
export type ScrollableItem =
|
||
| { type: 'visualLine'; lineText: string; absoluteVisualIdx: number }
|
||
| { type: 'ghostLine'; ghostLine: string; index: number };
|
||
|
||
export interface InputPromptProps {
|
||
onSubmit: (value: string) => void;
|
||
onClearScreen: () => void;
|
||
config: Config;
|
||
slashCommands: readonly SlashCommand[];
|
||
commandContext: CommandContext;
|
||
placeholder?: string;
|
||
focus?: boolean;
|
||
setShellModeActive: (value: boolean) => void;
|
||
approvalMode: ApprovalMode;
|
||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||
vimHandleInput?: (key: Key) => boolean;
|
||
isEmbeddedShellFocused?: boolean;
|
||
setQueueErrorMessage: (message: string | null) => void;
|
||
streamingState: StreamingState;
|
||
popAllMessages?: () => string | undefined;
|
||
onQueueMessage?: (message: string) => void;
|
||
suggestionsPosition?: 'above' | 'below';
|
||
setBannerVisible: (visible: boolean) => void;
|
||
}
|
||
|
||
// The input content, input container, and input suggestions list may have different widths
|
||
export const calculatePromptWidths = (mainContentWidth: number) => {
|
||
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
||
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
|
||
|
||
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
|
||
const suggestionsWidth = Math.max(20, mainContentWidth);
|
||
|
||
return {
|
||
inputWidth: Math.max(mainContentWidth - FRAME_OVERHEAD, 1),
|
||
containerWidth: mainContentWidth,
|
||
suggestionsWidth,
|
||
frameOverhead: FRAME_OVERHEAD,
|
||
} as const;
|
||
};
|
||
|
||
/**
|
||
* Returns true if the given text exceeds the thresholds for being considered a "large paste".
|
||
*/
|
||
export function isLargePaste(text: string): boolean {
|
||
const pasteLineCount = text.split('\n').length;
|
||
return (
|
||
pasteLineCount > LARGE_PASTE_LINE_THRESHOLD ||
|
||
text.length > LARGE_PASTE_CHAR_THRESHOLD
|
||
);
|
||
}
|
||
|
||
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
|
||
/**
|
||
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||
*/
|
||
export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
|
||
if (!buffer.pastedContent || Object.keys(buffer.pastedContent).length === 0) {
|
||
return false;
|
||
}
|
||
|
||
const [row, col] = buffer.cursor;
|
||
|
||
// 1. Check if cursor is on or immediately after a collapsed placeholder
|
||
const transform = getTransformUnderCursor(
|
||
row,
|
||
col,
|
||
buffer.transformationsByLine,
|
||
{ includeEdge: true },
|
||
);
|
||
if (transform?.type === 'paste' && transform.id) {
|
||
buffer.togglePasteExpansion(transform.id, row, col);
|
||
return true;
|
||
}
|
||
|
||
// 2. Check if cursor is inside an expanded paste region — collapse it
|
||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||
if (expandedId) {
|
||
buffer.togglePasteExpansion(expandedId, row, col);
|
||
return true;
|
||
}
|
||
|
||
// 3. Placeholders exist but cursor isn't on one — show hint
|
||
appEvents.emit(AppEvent.TransientMessage, {
|
||
message: 'Move cursor within placeholder to expand',
|
||
type: TransientMessageType.Hint,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||
onSubmit,
|
||
onClearScreen,
|
||
config,
|
||
slashCommands,
|
||
commandContext,
|
||
placeholder = ' Type your message or @path/to/file',
|
||
focus = true,
|
||
setShellModeActive,
|
||
approvalMode,
|
||
onEscapePromptChange,
|
||
onSuggestionsVisibilityChange,
|
||
vimHandleInput,
|
||
isEmbeddedShellFocused,
|
||
setQueueErrorMessage,
|
||
streamingState,
|
||
popAllMessages,
|
||
onQueueMessage,
|
||
suggestionsPosition = 'below',
|
||
setBannerVisible,
|
||
}) => {
|
||
const inputState = useInputState();
|
||
const {
|
||
buffer,
|
||
userMessages,
|
||
shellModeActive,
|
||
copyModeEnabled,
|
||
inputWidth,
|
||
suggestionsWidth,
|
||
} = inputState;
|
||
const isHelpDismissKey = useIsHelpDismissKey();
|
||
const keyMatchers = useKeyMatchers();
|
||
const { stdout } = useStdout();
|
||
const { merged: settings } = useSettings();
|
||
const kittyProtocol = useKittyKeyboardProtocol();
|
||
const isShellFocused = useShellFocusState();
|
||
const {
|
||
setEmbeddedShellFocused,
|
||
setShortcutsHelpVisible,
|
||
toggleCleanUiDetailsVisible,
|
||
setVoiceModeEnabled,
|
||
} = useUIActions();
|
||
const {
|
||
terminalWidth,
|
||
activePtyId,
|
||
history,
|
||
backgroundTasks,
|
||
backgroundTaskHeight,
|
||
shortcutsHelpVisible,
|
||
isVoiceModeEnabled,
|
||
} = useUIState();
|
||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||
useRepeatedKeyPress({
|
||
windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS,
|
||
});
|
||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||
const { handlePress: handleEscPress, resetCount: resetEscapeState } =
|
||
useRepeatedKeyPress({
|
||
windowMs: 500,
|
||
onRepeat: (count) => {
|
||
if (count === 1) {
|
||
setShowEscapePrompt(true);
|
||
} else if (count === 2) {
|
||
resetEscapeState();
|
||
if (buffer.text.length > 0) {
|
||
buffer.setText('');
|
||
resetTurnBaseline();
|
||
resetCompletionState();
|
||
} else if (history.length > 0) {
|
||
onSubmit('/rewind');
|
||
} else {
|
||
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||
}
|
||
}
|
||
},
|
||
onReset: () => setShowEscapePrompt(false),
|
||
});
|
||
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
|
||
number | null
|
||
>(null);
|
||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const innerBoxRef = useRef<DOMElement>(null);
|
||
const hasUserNavigatedSuggestions = useRef(false);
|
||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||
|
||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||
buffer,
|
||
config,
|
||
settings,
|
||
setQueueErrorMessage,
|
||
isVoiceModeEnabled,
|
||
setVoiceModeEnabled,
|
||
keyMatchers,
|
||
});
|
||
|
||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
|
||
0, 0,
|
||
]);
|
||
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
|
||
useState<number>(-1);
|
||
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
|
||
const shellHistoryData = shellHistory.history;
|
||
|
||
const completion = useCommandCompletion({
|
||
buffer,
|
||
cwd: config.getTargetDir(),
|
||
slashCommands,
|
||
commandContext,
|
||
reverseSearchActive,
|
||
shellModeActive,
|
||
config,
|
||
active: !suppressCompletion,
|
||
});
|
||
|
||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||
buffer,
|
||
shellHistoryData,
|
||
reverseSearchActive,
|
||
);
|
||
|
||
const reversedUserMessages = useMemo(
|
||
() => [...userMessages].reverse(),
|
||
[userMessages],
|
||
);
|
||
|
||
const commandSearchCompletion = useReverseSearchCompletion(
|
||
buffer,
|
||
reversedUserMessages,
|
||
commandSearchActive,
|
||
);
|
||
|
||
const resetCompletionState = completion.resetCompletionState;
|
||
const resetReverseSearchCompletionState =
|
||
reverseSearchCompletion.resetCompletionState;
|
||
const resetCommandSearchCompletionState =
|
||
commandSearchCompletion.resetCompletionState;
|
||
|
||
const getActiveCompletion = useCallback(() => {
|
||
if (commandSearchActive) return commandSearchCompletion;
|
||
if (reverseSearchActive) return reverseSearchCompletion;
|
||
return completion;
|
||
}, [
|
||
commandSearchActive,
|
||
commandSearchCompletion,
|
||
reverseSearchActive,
|
||
reverseSearchCompletion,
|
||
completion,
|
||
]);
|
||
|
||
const activeCompletion = getActiveCompletion();
|
||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||
|
||
const {
|
||
forceShowShellSuggestions,
|
||
setForceShowShellSuggestions,
|
||
isShellSuggestionsVisible,
|
||
} = completion;
|
||
|
||
const showCursor =
|
||
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
|
||
|
||
useEffect(() => {
|
||
appEvents.emit(AppEvent.ScrollToBottom);
|
||
}, [buffer.text, buffer.cursor]);
|
||
|
||
// Notify parent component about escape prompt state changes
|
||
useEffect(() => {
|
||
if (onEscapePromptChange) {
|
||
onEscapePromptChange(showEscapePrompt);
|
||
}
|
||
}, [showEscapePrompt, onEscapePromptChange]);
|
||
|
||
// Clear paste timeout on unmount
|
||
useEffect(
|
||
() => () => {
|
||
if (pasteTimeoutRef.current) {
|
||
clearTimeout(pasteTimeoutRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleSubmitAndClear = useCallback(
|
||
(submittedValue: string) => {
|
||
let processedValue = submittedValue;
|
||
if (buffer.pastedContent) {
|
||
processedValue = expandPastePlaceholders(
|
||
processedValue,
|
||
buffer.pastedContent,
|
||
);
|
||
}
|
||
|
||
if (shellModeActive) {
|
||
shellHistory.addCommandToHistory(processedValue);
|
||
}
|
||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||
buffer.setText('');
|
||
resetTurnBaseline();
|
||
onSubmit(processedValue);
|
||
resetCompletionState();
|
||
resetReverseSearchCompletionState();
|
||
},
|
||
[
|
||
buffer,
|
||
onSubmit,
|
||
resetCompletionState,
|
||
shellModeActive,
|
||
shellHistory,
|
||
resetReverseSearchCompletionState,
|
||
resetTurnBaseline,
|
||
],
|
||
);
|
||
|
||
const customSetTextAndResetCompletionSignal = useCallback(
|
||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||
buffer.setText(newText, cursorPosition);
|
||
setSuppressCompletion(true);
|
||
},
|
||
[buffer, setSuppressCompletion],
|
||
);
|
||
|
||
const inputHistory = useInputHistory({
|
||
userMessages,
|
||
onSubmit: handleSubmitAndClear,
|
||
isActive:
|
||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||
completion.suggestions.length === 1) &&
|
||
!shellModeActive,
|
||
currentQuery: buffer.text,
|
||
currentCursorOffset: buffer.getOffset(),
|
||
onChange: customSetTextAndResetCompletionSignal,
|
||
});
|
||
|
||
const handleSubmit = useCallback(
|
||
(submittedValue: string) => {
|
||
const trimmedMessage = submittedValue.trim();
|
||
const isSlash = isSlashCommand(trimmedMessage);
|
||
|
||
const isShell = shellModeActive;
|
||
if (
|
||
(isSlash || isShell) &&
|
||
streamingState === StreamingState.Responding
|
||
) {
|
||
if (isSlash) {
|
||
const { commandToExecute } = parseSlashCommand(
|
||
trimmedMessage,
|
||
slashCommands,
|
||
);
|
||
if (commandToExecute?.isSafeConcurrent) {
|
||
handleSubmitAndClear(trimmedMessage);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setQueueErrorMessage(
|
||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||
);
|
||
return;
|
||
}
|
||
inputHistory.handleSubmit(trimmedMessage);
|
||
},
|
||
[
|
||
inputHistory,
|
||
shellModeActive,
|
||
streamingState,
|
||
setQueueErrorMessage,
|
||
slashCommands,
|
||
handleSubmitAndClear,
|
||
],
|
||
);
|
||
|
||
// Effect to reset completion if history navigation just occurred and set the text
|
||
useEffect(() => {
|
||
if (suppressCompletion) {
|
||
resetCompletionState();
|
||
resetReverseSearchCompletionState();
|
||
resetCommandSearchCompletionState();
|
||
setExpandedSuggestionIndex(-1);
|
||
}
|
||
}, [
|
||
suppressCompletion,
|
||
buffer.text,
|
||
resetCompletionState,
|
||
setSuppressCompletion,
|
||
resetReverseSearchCompletionState,
|
||
resetCommandSearchCompletionState,
|
||
setExpandedSuggestionIndex,
|
||
]);
|
||
|
||
// Helper function to handle loading queued messages into input
|
||
// Returns true if we should continue with input history navigation
|
||
const tryLoadQueuedMessages = useCallback(() => {
|
||
if (buffer.text.trim() === '' && popAllMessages) {
|
||
const allMessages = popAllMessages();
|
||
if (allMessages) {
|
||
buffer.setText(allMessages);
|
||
return true;
|
||
} else {
|
||
// No queued messages, proceed with input history
|
||
inputHistory.navigateUp();
|
||
}
|
||
return true; // We handled the up arrow key
|
||
}
|
||
return false;
|
||
}, [buffer, popAllMessages, inputHistory]);
|
||
|
||
// Handle clipboard image pasting with Ctrl+V
|
||
const handleClipboardPaste = useCallback(async () => {
|
||
if (shortcutsHelpVisible) {
|
||
setShortcutsHelpVisible(false);
|
||
}
|
||
try {
|
||
if (await clipboardHasImage()) {
|
||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||
if (imagePath) {
|
||
// Clean up old images
|
||
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||
// Ignore cleanup errors
|
||
});
|
||
|
||
// Get relative path from current directory
|
||
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||
|
||
// Insert @path reference at cursor position
|
||
const insertText = `@${relativePath}`;
|
||
const currentText = buffer.text;
|
||
const offset = buffer.getOffset();
|
||
|
||
// Add spaces around the path if needed
|
||
let textToInsert = insertText;
|
||
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||
const charAfter =
|
||
offset < currentText.length ? currentText[offset] : '';
|
||
|
||
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||
textToInsert = ' ' + textToInsert;
|
||
}
|
||
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||
textToInsert = textToInsert + ' ';
|
||
}
|
||
|
||
// Insert at cursor position
|
||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||
}
|
||
}
|
||
|
||
if (settings.experimental?.useOSC52Paste) {
|
||
stdout.write('\x1b]52;c;?\x07');
|
||
} else {
|
||
const textToInsert = await clipboardy.read();
|
||
const escapedText = settings.ui?.escapePastedAtSymbols
|
||
? escapeAtSymbols(textToInsert)
|
||
: textToInsert;
|
||
buffer.insert(escapedText, { paste: true });
|
||
|
||
if (isLargePaste(textToInsert)) {
|
||
appEvents.emit(AppEvent.TransientMessage, {
|
||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||
type: TransientMessageType.Hint,
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
debugLogger.error('Error handling paste:', error);
|
||
}
|
||
}, [
|
||
buffer,
|
||
config,
|
||
stdout,
|
||
settings,
|
||
shortcutsHelpVisible,
|
||
setShortcutsHelpVisible,
|
||
]);
|
||
|
||
useMouseClick(
|
||
innerBoxRef,
|
||
(_event, relX, relY) => {
|
||
setSuppressCompletion(true);
|
||
if (isEmbeddedShellFocused) {
|
||
setEmbeddedShellFocused(false);
|
||
}
|
||
const currentScrollTop = Math.round(
|
||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||
);
|
||
const visualRow = currentScrollTop + relY;
|
||
buffer.moveToVisualPosition(visualRow, relX);
|
||
},
|
||
{ isActive: focus },
|
||
);
|
||
|
||
const isAlternateBuffer = useAlternateBuffer();
|
||
|
||
// Double-click to expand/collapse paste placeholders
|
||
useMouseClick(
|
||
innerBoxRef,
|
||
(_event, relX, relY) => {
|
||
if (!isAlternateBuffer) return;
|
||
|
||
const currentScrollTop = Math.round(
|
||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||
);
|
||
const visualLine = buffer.allVisualLines[currentScrollTop + relY];
|
||
if (!visualLine) return;
|
||
|
||
// Even if we click past the end of the line, we might want to collapse an expanded paste
|
||
const isPastEndOfLine = relX >= stringWidth(visualLine);
|
||
|
||
const logicalPos = isPastEndOfLine
|
||
? null
|
||
: buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX);
|
||
|
||
// Check for paste placeholder (collapsed state)
|
||
if (logicalPos) {
|
||
const transform = getTransformUnderCursor(
|
||
logicalPos.row,
|
||
logicalPos.col,
|
||
buffer.transformationsByLine,
|
||
{ includeEdge: true },
|
||
);
|
||
if (transform?.type === 'paste' && transform.id) {
|
||
buffer.togglePasteExpansion(
|
||
transform.id,
|
||
logicalPos.row,
|
||
logicalPos.col,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If we didn't click a placeholder to expand, check if we are inside or after
|
||
// an expanded paste region and collapse it.
|
||
const visualRow = currentScrollTop + relY;
|
||
const mapEntry = buffer.visualToLogicalMap[visualRow];
|
||
const row = mapEntry ? mapEntry[0] : visualRow;
|
||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||
if (expandedId) {
|
||
buffer.togglePasteExpansion(
|
||
expandedId,
|
||
row,
|
||
logicalPos?.col ?? relX, // Fallback to relX if past end of line
|
||
);
|
||
}
|
||
},
|
||
{ isActive: focus, name: 'double-click' },
|
||
);
|
||
|
||
useMouse(
|
||
(event: MouseEvent) => {
|
||
if (event.name === 'right-release') {
|
||
setSuppressCompletion(false);
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
handleClipboardPaste();
|
||
}
|
||
},
|
||
{ isActive: focus },
|
||
);
|
||
|
||
const handleInput = useCallback(
|
||
(key: Key) => {
|
||
if (handleVoiceInput(key)) return true;
|
||
|
||
// Determine if this keypress is a history navigation command
|
||
const isHistoryUp =
|
||
!shellModeActive &&
|
||
(keyMatchers[Command.HISTORY_UP](key) ||
|
||
(keyMatchers[Command.NAVIGATION_UP](key) &&
|
||
(buffer.allVisualLines.length === 1 ||
|
||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))));
|
||
const isHistoryDown =
|
||
!shellModeActive &&
|
||
(keyMatchers[Command.HISTORY_DOWN](key) ||
|
||
(keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||
(buffer.allVisualLines.length === 1 ||
|
||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)));
|
||
|
||
const isHistoryNav = isHistoryUp || isHistoryDown;
|
||
const isCursorMovement =
|
||
keyMatchers[Command.MOVE_LEFT](key) ||
|
||
keyMatchers[Command.MOVE_RIGHT](key) ||
|
||
keyMatchers[Command.MOVE_UP](key) ||
|
||
keyMatchers[Command.MOVE_DOWN](key) ||
|
||
keyMatchers[Command.MOVE_WORD_LEFT](key) ||
|
||
keyMatchers[Command.MOVE_WORD_RIGHT](key) ||
|
||
keyMatchers[Command.HOME](key) ||
|
||
keyMatchers[Command.END](key);
|
||
|
||
const isSuggestionsNav =
|
||
shouldShowSuggestions &&
|
||
(keyMatchers[Command.COMPLETION_UP](key) ||
|
||
keyMatchers[Command.COMPLETION_DOWN](key) ||
|
||
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
|
||
keyMatchers[Command.COLLAPSE_SUGGESTION](key) ||
|
||
keyMatchers[Command.ACCEPT_SUGGESTION](key));
|
||
|
||
// Reset completion suppression if the user performs any action other than
|
||
// history navigation or cursor movement.
|
||
// We explicitly skip this if we are currently navigating suggestions.
|
||
if (!isSuggestionsNav) {
|
||
setSuppressCompletion(
|
||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||
);
|
||
hasUserNavigatedSuggestions.current = false;
|
||
|
||
if (key.name !== 'tab') {
|
||
setForceShowShellSuggestions(false);
|
||
}
|
||
}
|
||
|
||
// 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.name !== 'paste') {
|
||
return false;
|
||
}
|
||
|
||
// Handle escape to close shortcuts panel first, before letting it bubble
|
||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||
// and pressing again cancels the operation.
|
||
if (shortcutsHelpVisible && key.name === 'escape') {
|
||
setShortcutsHelpVisible(false);
|
||
return true;
|
||
}
|
||
|
||
const isGenerating =
|
||
streamingState === StreamingState.Responding ||
|
||
streamingState === StreamingState.WaitingForConfirmation;
|
||
|
||
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
|
||
const isPlainTab =
|
||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||
const hasTabCompletionInteraction =
|
||
(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||
Boolean(completion.promptCompletion.text) ||
|
||
reverseSearchActive ||
|
||
commandSearchActive;
|
||
|
||
if (
|
||
isGenerating &&
|
||
isQueueMessageKey &&
|
||
!hasTabCompletionInteraction &&
|
||
buffer.text.trim().length > 0
|
||
) {
|
||
const trimmedMessage = buffer.text.trim();
|
||
const isSlash = isSlashCommand(trimmedMessage);
|
||
|
||
if (isSlash || shellModeActive) {
|
||
setQueueErrorMessage(
|
||
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||
);
|
||
} else if (onQueueMessage) {
|
||
onQueueMessage(buffer.text);
|
||
buffer.setText('');
|
||
resetCompletionState();
|
||
resetReverseSearchCompletionState();
|
||
}
|
||
resetPlainTabPress();
|
||
return true;
|
||
}
|
||
|
||
if (isPlainTab && shellModeActive) {
|
||
resetPlainTabPress();
|
||
if (!shouldShowSuggestions) {
|
||
setSuppressCompletion(false);
|
||
if (completion.promptCompletion.text) {
|
||
completion.promptCompletion.accept();
|
||
return true;
|
||
} else if (
|
||
completion.suggestions.length > 0 &&
|
||
!forceShowShellSuggestions
|
||
) {
|
||
setForceShowShellSuggestions(true);
|
||
return true;
|
||
}
|
||
}
|
||
} else if (isPlainTab) {
|
||
if (!hasTabCompletionInteraction) {
|
||
if (registerPlainTabPress() === 2) {
|
||
toggleCleanUiDetailsVisible();
|
||
resetPlainTabPress();
|
||
return true;
|
||
}
|
||
} else {
|
||
resetPlainTabPress();
|
||
}
|
||
} else {
|
||
resetPlainTabPress();
|
||
}
|
||
|
||
if (key.name === 'paste') {
|
||
if (shortcutsHelpVisible) {
|
||
setShortcutsHelpVisible(false);
|
||
}
|
||
// Record paste time to prevent accidental auto-submission
|
||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||
setRecentUnsafePasteTime(Date.now());
|
||
|
||
// Clear any existing paste timeout
|
||
if (pasteTimeoutRef.current) {
|
||
clearTimeout(pasteTimeoutRef.current);
|
||
}
|
||
|
||
// Clear the paste protection after a very short delay to prevent
|
||
// false positives.
|
||
// Due to how we use a reducer for text buffer state updates, it is
|
||
// reasonable to expect that key events that are really part of the
|
||
// same paste will be processed in the same event loop tick. 40ms
|
||
// is chosen arbitrarily as it is faster than a typical human
|
||
// could go from pressing paste to pressing enter. The fastest typists
|
||
// can type at 200 words per minute which roughly translates to 50ms
|
||
// per letter.
|
||
pasteTimeoutRef.current = setTimeout(() => {
|
||
setRecentUnsafePasteTime(null);
|
||
pasteTimeoutRef.current = null;
|
||
}, 40);
|
||
}
|
||
if (settings.ui?.escapePastedAtSymbols) {
|
||
buffer.handleInput({
|
||
...key,
|
||
sequence: escapeAtSymbols(key.sequence || ''),
|
||
});
|
||
} else {
|
||
buffer.handleInput(key);
|
||
}
|
||
|
||
if (key.sequence && isLargePaste(key.sequence)) {
|
||
appEvents.emit(AppEvent.TransientMessage, {
|
||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||
type: TransientMessageType.Hint,
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
|
||
setShortcutsHelpVisible(false);
|
||
}
|
||
|
||
if (shortcutsHelpVisible) {
|
||
if (key.sequence === '?' && key.insertable) {
|
||
setShortcutsHelpVisible(false);
|
||
buffer.handleInput(key);
|
||
return true;
|
||
}
|
||
// Escape is handled earlier to ensure it closes the panel before
|
||
// potentially cancelling an operation
|
||
if (key.name === 'backspace' || key.sequence === '\b') {
|
||
setShortcutsHelpVisible(false);
|
||
return true;
|
||
}
|
||
if (key.insertable) {
|
||
setShortcutsHelpVisible(false);
|
||
}
|
||
}
|
||
|
||
if (
|
||
key.sequence === '?' &&
|
||
key.insertable &&
|
||
!shortcutsHelpVisible &&
|
||
buffer.text.length === 0
|
||
) {
|
||
setShortcutsHelpVisible(true);
|
||
return true;
|
||
}
|
||
|
||
if (vimHandleInput && vimHandleInput(key)) {
|
||
return true;
|
||
}
|
||
|
||
// Reset ESC count and hide prompt on any non-ESC key
|
||
if (key.name !== 'escape') {
|
||
resetEscapeState();
|
||
}
|
||
|
||
// Ctrl+O to expand/collapse paste placeholders
|
||
if (keyMatchers[Command.EXPAND_PASTE](key)) {
|
||
const handled = tryTogglePasteExpansion(buffer);
|
||
if (handled) return true;
|
||
}
|
||
|
||
if (
|
||
key.sequence === '!' &&
|
||
buffer.text === '' &&
|
||
!(completion.showSuggestions && isShellSuggestionsVisible)
|
||
) {
|
||
setShellModeActive(!shellModeActive);
|
||
buffer.setText(''); // Clear the '!' from input
|
||
resetTurnBaseline();
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.ESCAPE](key)) {
|
||
const cancelSearch = (
|
||
setActive: (active: boolean) => void,
|
||
resetCompletion: () => void,
|
||
) => {
|
||
setActive(false);
|
||
resetCompletion();
|
||
buffer.setText(textBeforeReverseSearch);
|
||
const offset = logicalPosToOffset(
|
||
buffer.lines,
|
||
cursorPosition[0],
|
||
cursorPosition[1],
|
||
);
|
||
buffer.moveToOffset(offset);
|
||
setExpandedSuggestionIndex(-1);
|
||
};
|
||
|
||
if (reverseSearchActive) {
|
||
cancelSearch(
|
||
setReverseSearchActive,
|
||
reverseSearchCompletion.resetCompletionState,
|
||
);
|
||
return true;
|
||
}
|
||
if (commandSearchActive) {
|
||
cancelSearch(
|
||
setCommandSearchActive,
|
||
commandSearchCompletion.resetCompletionState,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||
completion.resetCompletionState();
|
||
setExpandedSuggestionIndex(-1);
|
||
resetEscapeState();
|
||
return true;
|
||
}
|
||
|
||
if (shellModeActive) {
|
||
setShellModeActive(false);
|
||
resetEscapeState();
|
||
return true;
|
||
}
|
||
|
||
// If we're generating and no local overlay consumed Escape, let it
|
||
// propagate to the global cancellation handler.
|
||
if (isGenerating) {
|
||
return false;
|
||
}
|
||
|
||
handleEscPress();
|
||
return true;
|
||
}
|
||
|
||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||
setBannerVisible(false);
|
||
onClearScreen();
|
||
return true;
|
||
}
|
||
|
||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||
setReverseSearchActive(true);
|
||
setTextBeforeReverseSearch(buffer.text);
|
||
setCursorPosition(buffer.cursor);
|
||
return true;
|
||
}
|
||
|
||
if (reverseSearchActive || commandSearchActive) {
|
||
const isCommandSearch = commandSearchActive;
|
||
|
||
const sc = isCommandSearch
|
||
? commandSearchCompletion
|
||
: reverseSearchCompletion;
|
||
|
||
const {
|
||
activeSuggestionIndex,
|
||
navigateUp,
|
||
navigateDown,
|
||
showSuggestions,
|
||
suggestions,
|
||
} = sc;
|
||
const setActive = isCommandSearch
|
||
? setCommandSearchActive
|
||
: setReverseSearchActive;
|
||
const resetState = sc.resetCompletionState;
|
||
|
||
if (showSuggestions) {
|
||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||
navigateUp();
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||
navigateDown();
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||
setExpandedSuggestionIndex(-1);
|
||
return true;
|
||
}
|
||
}
|
||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||
return true;
|
||
}
|
||
}
|
||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||
sc.handleAutocomplete(activeSuggestionIndex);
|
||
resetState();
|
||
setActive(false);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
|
||
const textToSubmit =
|
||
showSuggestions && activeSuggestionIndex > -1
|
||
? suggestions[activeSuggestionIndex].value
|
||
: buffer.text;
|
||
handleSubmit(textToSubmit);
|
||
resetState();
|
||
setActive(false);
|
||
return true;
|
||
}
|
||
|
||
// Prevent up/down from falling through to regular history navigation
|
||
if (
|
||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// If the command is a perfect match, pressing enter should execute it.
|
||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||
if (
|
||
completion.isPerfectMatch &&
|
||
keyMatchers[Command.SUBMIT](key) &&
|
||
recentUnsafePasteTime === null &&
|
||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||
(completion.activeSuggestionIndex <= 0 &&
|
||
!hasUserNavigatedSuggestions.current))
|
||
) {
|
||
handleSubmit(buffer.text);
|
||
return true;
|
||
}
|
||
|
||
// Newline insertion
|
||
if (keyMatchers[Command.NEWLINE](key)) {
|
||
buffer.newline();
|
||
return true;
|
||
}
|
||
|
||
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||
if (completion.suggestions.length > 1) {
|
||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||
completion.navigateUp();
|
||
hasUserNavigatedSuggestions.current = true;
|
||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||
completion.navigateDown();
|
||
hasUserNavigatedSuggestions.current = true;
|
||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
|
||
if (completion.suggestions.length > 0) {
|
||
const targetIndex =
|
||
completion.activeSuggestionIndex === -1
|
||
? 0 // Default to the first if none is active
|
||
: completion.activeSuggestionIndex;
|
||
|
||
if (targetIndex < completion.suggestions.length) {
|
||
const suggestion = completion.suggestions[targetIndex];
|
||
|
||
const isEnterKey = key.name === 'enter' && !key.ctrl;
|
||
|
||
if (isEnterKey && shellModeActive) {
|
||
if (hasUserNavigatedSuggestions.current) {
|
||
completion.handleAutocomplete(
|
||
completion.activeSuggestionIndex,
|
||
);
|
||
setExpandedSuggestionIndex(-1);
|
||
hasUserNavigatedSuggestions.current = false;
|
||
return true;
|
||
}
|
||
completion.resetCompletionState();
|
||
setExpandedSuggestionIndex(-1);
|
||
hasUserNavigatedSuggestions.current = false;
|
||
if (buffer.text.trim()) {
|
||
handleSubmit(buffer.text);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (isEnterKey && buffer.text.startsWith('/')) {
|
||
if (suggestion.submitValue) {
|
||
setExpandedSuggestionIndex(-1);
|
||
handleSubmit(suggestion.submitValue.trim());
|
||
return true;
|
||
}
|
||
|
||
const { isArgumentCompletion, leafCommand } =
|
||
completion.slashCompletionRange;
|
||
|
||
if (
|
||
isArgumentCompletion &&
|
||
isAutoExecutableCommand(leafCommand)
|
||
) {
|
||
// isArgumentCompletion guarantees leafCommand exists
|
||
const completedText = completion.getCompletedText(suggestion);
|
||
if (completedText) {
|
||
setExpandedSuggestionIndex(-1);
|
||
handleSubmit(completedText.trim());
|
||
return true;
|
||
}
|
||
} else if (!isArgumentCompletion) {
|
||
// Existing logic for command name completion
|
||
const command =
|
||
completion.getCommandFromSuggestion(suggestion);
|
||
|
||
// Only auto-execute if the command has no completion function
|
||
// (i.e., it doesn't require an argument to be selected)
|
||
if (
|
||
command &&
|
||
isAutoExecutableCommand(command) &&
|
||
!command.completion
|
||
) {
|
||
const completedText =
|
||
completion.getCompletedText(suggestion);
|
||
|
||
if (completedText) {
|
||
setExpandedSuggestionIndex(-1);
|
||
handleSubmit(completedText.trim());
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default behavior: auto-complete to prompt box
|
||
completion.handleAutocomplete(targetIndex);
|
||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Handle Tab key for ghost text acceptance
|
||
if (
|
||
key.name === 'tab' &&
|
||
!key.shift &&
|
||
!(completion.showSuggestions && isShellSuggestionsVisible) &&
|
||
completion.promptCompletion.text
|
||
) {
|
||
completion.promptCompletion.accept();
|
||
return true;
|
||
}
|
||
|
||
if (!shellModeActive) {
|
||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||
setCommandSearchActive(true);
|
||
setTextBeforeReverseSearch(buffer.text);
|
||
setCursorPosition(buffer.cursor);
|
||
return true;
|
||
}
|
||
|
||
if (isHistoryUp) {
|
||
if (
|
||
keyMatchers[Command.NAVIGATION_UP](key) &&
|
||
buffer.visualCursor[1] > 0
|
||
) {
|
||
buffer.move('home');
|
||
return true;
|
||
}
|
||
// Check for queued messages first when input is empty
|
||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||
if (tryLoadQueuedMessages()) {
|
||
return true;
|
||
}
|
||
// Only navigate history if popAllMessages doesn't exist
|
||
inputHistory.navigateUp();
|
||
return true;
|
||
}
|
||
if (isHistoryDown) {
|
||
if (
|
||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||
buffer.visualCursor[1] <
|
||
cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
|
||
) {
|
||
buffer.move('end');
|
||
return true;
|
||
}
|
||
inputHistory.navigateDown();
|
||
return true;
|
||
}
|
||
} else {
|
||
// Shell History Navigation
|
||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||
if (
|
||
(buffer.allVisualLines.length === 1 ||
|
||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
|
||
buffer.visualCursor[1] > 0
|
||
) {
|
||
buffer.move('home');
|
||
return true;
|
||
}
|
||
const prevCommand = shellHistory.getPreviousCommand();
|
||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||
if (
|
||
(buffer.allVisualLines.length === 1 ||
|
||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
|
||
buffer.visualCursor[1] <
|
||
cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
|
||
) {
|
||
buffer.move('end');
|
||
return true;
|
||
}
|
||
const nextCommand = shellHistory.getNextCommand();
|
||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (keyMatchers[Command.SUBMIT](key)) {
|
||
if (buffer.text.trim()) {
|
||
// Check if a paste operation occurred recently to prevent accidental auto-submission
|
||
if (recentUnsafePasteTime !== null) {
|
||
// Paste occurred recently in a terminal where we don't trust pastes
|
||
// to be reported correctly so assume this paste was really a
|
||
// newline that was part of the paste.
|
||
// This has the added benefit that in the worst case at least users
|
||
// get some feedback that their keypress was handled rather than
|
||
// wondering why it was completely ignored.
|
||
buffer.newline();
|
||
return true;
|
||
}
|
||
|
||
const [row, col] = buffer.cursor;
|
||
const line = buffer.lines[row];
|
||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||
if (charBefore === '\\') {
|
||
buffer.backspace();
|
||
buffer.newline();
|
||
} else {
|
||
handleSubmit(buffer.text);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Ctrl+A (Home) / Ctrl+E (End)
|
||
if (keyMatchers[Command.HOME](key)) {
|
||
buffer.move('home');
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.END](key)) {
|
||
buffer.move('end');
|
||
return true;
|
||
}
|
||
|
||
// Kill line commands
|
||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||
buffer.killLineRight();
|
||
return true;
|
||
}
|
||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||
buffer.killLineLeft();
|
||
return true;
|
||
}
|
||
|
||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||
buffer.deleteWordLeft();
|
||
return true;
|
||
}
|
||
|
||
// External editor
|
||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
buffer.openInExternalEditor();
|
||
return true;
|
||
}
|
||
|
||
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
|
||
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
|
||
appEvents.emit(AppEvent.TransientMessage, {
|
||
message: `Use ${cmdKey} to open the external editor.`,
|
||
type: TransientMessageType.Hint,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// Ctrl+V for clipboard paste
|
||
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
handleClipboardPaste();
|
||
return true;
|
||
}
|
||
|
||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||
return false;
|
||
}
|
||
|
||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||
if (
|
||
activePtyId ||
|
||
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
|
||
) {
|
||
setEmbeddedShellFocused(true);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Fall back to the text buffer's default input handling for all other keys
|
||
const handled = buffer.handleInput(key);
|
||
|
||
if (handled) {
|
||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||
resetCompletionState();
|
||
}
|
||
|
||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||
if (
|
||
completion.promptCompletion.text &&
|
||
key.sequence &&
|
||
key.sequence.length === 1 &&
|
||
!key.alt &&
|
||
!key.ctrl &&
|
||
!key.cmd
|
||
) {
|
||
completion.promptCompletion.clear();
|
||
setExpandedSuggestionIndex(-1);
|
||
}
|
||
}
|
||
return handled;
|
||
},
|
||
[
|
||
focus,
|
||
buffer,
|
||
completion,
|
||
setForceShowShellSuggestions,
|
||
shellModeActive,
|
||
setShellModeActive,
|
||
onClearScreen,
|
||
inputHistory,
|
||
handleSubmit,
|
||
shellHistory,
|
||
reverseSearchCompletion,
|
||
handleClipboardPaste,
|
||
resetCompletionState,
|
||
resetEscapeState,
|
||
vimHandleInput,
|
||
reverseSearchActive,
|
||
textBeforeReverseSearch,
|
||
cursorPosition,
|
||
recentUnsafePasteTime,
|
||
commandSearchActive,
|
||
commandSearchCompletion,
|
||
kittyProtocol.enabled,
|
||
shortcutsHelpVisible,
|
||
setShortcutsHelpVisible,
|
||
tryLoadQueuedMessages,
|
||
onQueueMessage,
|
||
setQueueErrorMessage,
|
||
resetReverseSearchCompletionState,
|
||
setBannerVisible,
|
||
activePtyId,
|
||
setEmbeddedShellFocused,
|
||
backgroundTasks.size,
|
||
backgroundTaskHeight,
|
||
streamingState,
|
||
handleEscPress,
|
||
resetTurnBaseline,
|
||
registerPlainTabPress,
|
||
resetPlainTabPress,
|
||
toggleCleanUiDetailsVisible,
|
||
shouldShowSuggestions,
|
||
isShellSuggestionsVisible,
|
||
forceShowShellSuggestions,
|
||
keyMatchers,
|
||
isHelpDismissKey,
|
||
settings,
|
||
handleVoiceInput,
|
||
],
|
||
);
|
||
useKeypress(handleInput, {
|
||
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
|
||
priority: true,
|
||
});
|
||
|
||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||
buffer.visualCursor;
|
||
|
||
const getGhostTextLines = useCallback(() => {
|
||
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: [] };
|
||
}
|
||
|
||
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
|
||
const cursorCol = buffer.cursor[1];
|
||
|
||
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
|
||
const usedWidth = stringWidth(textBeforeCursor);
|
||
const remainingWidth = Math.max(0, inputWidth - usedWidth);
|
||
|
||
const ghostTextLinesRaw = ghostSuffix.split('\n');
|
||
const firstLineRaw = ghostTextLinesRaw.shift() || '';
|
||
|
||
let inlineGhost = '';
|
||
let remainingFirstLine = '';
|
||
|
||
if (stringWidth(firstLineRaw) <= remainingWidth) {
|
||
inlineGhost = firstLineRaw;
|
||
} else {
|
||
const words = firstLineRaw.split(' ');
|
||
let currentLine = '';
|
||
let wordIdx = 0;
|
||
for (const word of words) {
|
||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||
if (stringWidth(prospectiveLine) > remainingWidth) {
|
||
break;
|
||
}
|
||
currentLine = prospectiveLine;
|
||
wordIdx++;
|
||
}
|
||
inlineGhost = currentLine;
|
||
if (words.length > wordIdx) {
|
||
remainingFirstLine = words.slice(wordIdx).join(' ');
|
||
}
|
||
}
|
||
|
||
const linesToWrap = [];
|
||
if (remainingFirstLine) {
|
||
linesToWrap.push(remainingFirstLine);
|
||
}
|
||
linesToWrap.push(...ghostTextLinesRaw);
|
||
const remainingGhostText = linesToWrap.join('\n');
|
||
|
||
const additionalLines: string[] = [];
|
||
if (remainingGhostText) {
|
||
const textLines = remainingGhostText.split('\n');
|
||
for (const textLine of textLines) {
|
||
const words = textLine.split(' ');
|
||
let currentLine = '';
|
||
|
||
for (const word of words) {
|
||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||
const prospectiveWidth = stringWidth(prospectiveLine);
|
||
|
||
if (prospectiveWidth > inputWidth) {
|
||
if (currentLine) {
|
||
additionalLines.push(currentLine);
|
||
}
|
||
|
||
let wordToProcess = word;
|
||
while (stringWidth(wordToProcess) > inputWidth) {
|
||
let part = '';
|
||
const wordCP = toCodePoints(wordToProcess);
|
||
let partWidth = 0;
|
||
let splitIndex = 0;
|
||
for (let i = 0; i < wordCP.length; i++) {
|
||
const char = wordCP[i];
|
||
const charWidth = stringWidth(char);
|
||
if (partWidth + charWidth > inputWidth) {
|
||
break;
|
||
}
|
||
part += char;
|
||
partWidth += charWidth;
|
||
splitIndex = i + 1;
|
||
}
|
||
additionalLines.push(part);
|
||
wordToProcess = cpSlice(wordToProcess, splitIndex);
|
||
}
|
||
currentLine = wordToProcess;
|
||
} else {
|
||
currentLine = prospectiveLine;
|
||
}
|
||
}
|
||
if (currentLine) {
|
||
additionalLines.push(currentLine);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { inlineGhost, additionalLines };
|
||
}, [
|
||
completion.promptCompletion.text,
|
||
buffer.text,
|
||
buffer.lines,
|
||
buffer.cursor,
|
||
inputWidth,
|
||
]);
|
||
|
||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||
|
||
const scrollableData = useMemo(() => {
|
||
const items: ScrollableItem[] = buffer.allVisualLines.map(
|
||
(lineText, index) => ({
|
||
type: 'visualLine',
|
||
lineText,
|
||
absoluteVisualIdx: index,
|
||
}),
|
||
);
|
||
|
||
additionalLines.forEach((ghostLine, index) => {
|
||
items.push({
|
||
type: 'ghostLine',
|
||
ghostLine,
|
||
index,
|
||
});
|
||
});
|
||
|
||
return items;
|
||
}, [buffer.allVisualLines, additionalLines]);
|
||
|
||
const renderItem = useCallback(
|
||
({ item }: { item: ScrollableItem; index: number }) => {
|
||
if (item.type === 'ghostLine') {
|
||
const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine));
|
||
return (
|
||
<Box height={1}>
|
||
<Text color={theme.text.secondary}>
|
||
{item.ghostLine}
|
||
{' '.repeat(padding)}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const { lineText, absoluteVisualIdx } = item;
|
||
// console.log('renderItem called with:', lineText);
|
||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||
if (!mapEntry) return <Text> </Text>;
|
||
|
||
const isOnCursorLine =
|
||
focus && absoluteVisualIdx === cursorVisualRowAbsolute;
|
||
const renderedLine: React.ReactNode[] = [];
|
||
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 visualStartCol =
|
||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||
const segments = parseSegmentsFromTokens(
|
||
tokens,
|
||
visualStartCol,
|
||
visualEndCol,
|
||
);
|
||
let charCount = 0;
|
||
segments.forEach((seg, segIdx) => {
|
||
const segLen = cpLen(seg.text);
|
||
let display = seg.text;
|
||
if (isOnCursorLine) {
|
||
const relCol = cursorVisualColAbsolute;
|
||
const segStart = charCount;
|
||
const segEnd = segStart + segLen;
|
||
if (relCol >= segStart && relCol < segEnd) {
|
||
const charToHighlight = cpSlice(
|
||
display,
|
||
relCol - segStart,
|
||
relCol - segStart + 1,
|
||
);
|
||
const highlighted = showCursor
|
||
? chalk.inverse(charToHighlight)
|
||
: charToHighlight;
|
||
display =
|
||
cpSlice(display, 0, relCol - segStart) +
|
||
highlighted +
|
||
cpSlice(display, relCol - segStart + 1);
|
||
}
|
||
charCount = segEnd;
|
||
} else {
|
||
charCount += segLen;
|
||
}
|
||
const color =
|
||
seg.type === 'command' || seg.type === 'file' || seg.type === 'paste'
|
||
? 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) &&
|
||
!currentLineGhost
|
||
) {
|
||
renderedLine.push(
|
||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||
{showCursor ? chalk.inverse(' ') : ' '}
|
||
</Text>,
|
||
);
|
||
}
|
||
const showCursorBeforeGhost =
|
||
focus &&
|
||
isOnCursorLine &&
|
||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||
currentLineGhost;
|
||
return (
|
||
<Box height={1}>
|
||
<Text
|
||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||
terminalCursorPosition={cpIndexToOffset(
|
||
lineText,
|
||
cursorVisualColAbsolute,
|
||
)}
|
||
>
|
||
{renderedLine}
|
||
{showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')}
|
||
{currentLineGhost && (
|
||
<Text color={theme.text.secondary}>{currentLineGhost}</Text>
|
||
)}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
},
|
||
[
|
||
buffer.visualToLogicalMap,
|
||
buffer.lines,
|
||
buffer.transformationsByLine,
|
||
buffer.cursor,
|
||
buffer.visualToTransformedMap,
|
||
focus,
|
||
cursorVisualRowAbsolute,
|
||
cursorVisualColAbsolute,
|
||
showCursor,
|
||
inlineGhost,
|
||
inputWidth,
|
||
],
|
||
);
|
||
|
||
const useBackgroundColor = config.getUseBackgroundColor();
|
||
|
||
const prevCursorRef = useRef(buffer.visualCursor);
|
||
const prevTextRef = useRef(buffer.text);
|
||
|
||
// Effect to ensure cursor remains visible after interactions
|
||
useEffect(() => {
|
||
const cursorChanged = prevCursorRef.current !== buffer.visualCursor;
|
||
const textChanged = prevTextRef.current !== buffer.text;
|
||
|
||
prevCursorRef.current = buffer.visualCursor;
|
||
prevTextRef.current = buffer.text;
|
||
|
||
if (!cursorChanged && !textChanged) return;
|
||
|
||
if (!listRef.current || !focus) return;
|
||
const { scrollTop, innerHeight } = listRef.current.getScrollState();
|
||
if (innerHeight === 0) return;
|
||
|
||
const cursorVisualRow = buffer.visualCursor[0];
|
||
const actualScrollTop = Math.round(scrollTop);
|
||
|
||
// If cursor is out of the currently visible viewport...
|
||
if (
|
||
cursorVisualRow < actualScrollTop ||
|
||
cursorVisualRow >= actualScrollTop + innerHeight
|
||
) {
|
||
// Calculate minimal scroll to make it visible
|
||
let newScrollTop = actualScrollTop;
|
||
if (cursorVisualRow < actualScrollTop) {
|
||
newScrollTop = cursorVisualRow;
|
||
} else if (cursorVisualRow >= actualScrollTop + innerHeight) {
|
||
newScrollTop = cursorVisualRow - innerHeight + 1;
|
||
}
|
||
|
||
listRef.current.scrollToIndex({ index: newScrollTop });
|
||
}
|
||
}, [buffer.visualCursor, buffer.text, focus]);
|
||
|
||
const listBackgroundColor = !useBackgroundColor
|
||
? undefined
|
||
: theme.background.input;
|
||
|
||
const useLineFallback = !!process.env['NO_COLOR'];
|
||
|
||
useEffect(() => {
|
||
if (onSuggestionsVisibilityChange) {
|
||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||
}
|
||
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
||
|
||
const showAutoAcceptStyling =
|
||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||
const showYoloStyling =
|
||
!shellModeActive && approvalMode === ApprovalMode.YOLO;
|
||
const showPlanStyling =
|
||
!shellModeActive && approvalMode === ApprovalMode.PLAN;
|
||
|
||
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 (showPlanStyling) {
|
||
statusColor = theme.status.success;
|
||
statusText = 'Plan mode';
|
||
} else if (showAutoAcceptStyling) {
|
||
statusColor = theme.status.warning;
|
||
statusText = 'Accepting edits';
|
||
}
|
||
|
||
const suggestionsNode = shouldShowSuggestions ? (
|
||
<Box paddingRight={2}>
|
||
<SuggestionsDisplay
|
||
suggestions={activeCompletion.suggestions}
|
||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||
isLoading={activeCompletion.isLoadingSuggestions}
|
||
width={suggestionsWidth}
|
||
scrollOffset={activeCompletion.visibleStartIndex}
|
||
userInput={buffer.text}
|
||
mode={
|
||
completion.completionMode === CompletionMode.AT ||
|
||
completion.completionMode === CompletionMode.SHELL
|
||
? 'reverse'
|
||
: buffer.text.startsWith('/') &&
|
||
!reverseSearchActive &&
|
||
!commandSearchActive
|
||
? 'slash'
|
||
: 'reverse'
|
||
}
|
||
expandedIndex={expandedSuggestionIndex}
|
||
/>
|
||
</Box>
|
||
) : null;
|
||
|
||
const borderColor =
|
||
isShellFocused && !isEmbeddedShellFocused
|
||
? (statusColor ?? theme.ui.focus)
|
||
: theme.border.default;
|
||
|
||
return (
|
||
<>
|
||
{suggestionsPosition === 'above' && suggestionsNode}
|
||
{useLineFallback || !useBackgroundColor ? (
|
||
<Box
|
||
borderStyle="round"
|
||
borderTop={true}
|
||
borderBottom={false}
|
||
borderLeft={false}
|
||
borderRight={false}
|
||
borderColor={borderColor}
|
||
width={terminalWidth}
|
||
flexDirection="row"
|
||
alignItems="flex-start"
|
||
height={0}
|
||
/>
|
||
) : null}
|
||
<HalfLinePaddedBox
|
||
backgroundBaseColor={theme.background.input}
|
||
backgroundOpacity={1}
|
||
useBackgroundColor={useBackgroundColor}
|
||
>
|
||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||
<Text
|
||
color={statusColor ?? theme.text.accent}
|
||
aria-label={statusText || undefined}
|
||
>
|
||
{shellModeActive ? (
|
||
reverseSearchActive ? (
|
||
<Text
|
||
color={theme.text.link}
|
||
aria-label={SCREEN_READER_USER_PREFIX}
|
||
>
|
||
(r:){' '}
|
||
</Text>
|
||
) : (
|
||
'!'
|
||
)
|
||
) : commandSearchActive ? (
|
||
<Text color={theme.text.accent}>(r:) </Text>
|
||
) : showYoloStyling ? (
|
||
'*'
|
||
) : (
|
||
'>'
|
||
)}{' '}
|
||
</Text>
|
||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||
{isRecording && (
|
||
<Box flexDirection="row" marginBottom={0}>
|
||
<Text color={theme.status.success}>🎙️ Listening...</Text>
|
||
</Box>
|
||
)}
|
||
{isVoiceModeEnabled && !isRecording && (
|
||
<Box flexDirection="row" marginBottom={0}>
|
||
<Text color={theme.text.secondary}>
|
||
> Voice mode:{' '}
|
||
{(settings.experimental.voice?.activationMode ??
|
||
'push-to-talk') === 'push-to-talk'
|
||
? 'Hold Space to record'
|
||
: 'Space to start/stop recording'}{' '}
|
||
(Esc to exit)
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
{buffer.text.length === 0 && !isRecording ? (
|
||
!isVoiceModeEnabled && placeholder ? (
|
||
showCursor ? (
|
||
<Text
|
||
terminalCursorFocus={showCursor}
|
||
terminalCursorPosition={0}
|
||
>
|
||
{chalk.inverse(placeholder.slice(0, 1))}
|
||
<Text color={theme.text.secondary}>
|
||
{placeholder.slice(1)}
|
||
</Text>
|
||
</Text>
|
||
) : (
|
||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||
)
|
||
) : null
|
||
) : (
|
||
<Box
|
||
flexDirection="column"
|
||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||
width="100%"
|
||
>
|
||
{config.getUseTerminalBuffer() ? (
|
||
<ScrollableList
|
||
ref={listRef}
|
||
hasFocus={focus}
|
||
data={scrollableData}
|
||
renderItem={renderItem}
|
||
estimatedItemHeight={() => 1}
|
||
fixedItemHeight={true}
|
||
keyExtractor={(item) =>
|
||
item.type === 'visualLine'
|
||
? `line-${item.absoluteVisualIdx}`
|
||
: `ghost-${item.index}`
|
||
}
|
||
width={inputWidth}
|
||
backgroundColor={listBackgroundColor}
|
||
containerHeight={Math.min(
|
||
buffer.viewportHeight,
|
||
scrollableData.length,
|
||
)}
|
||
/>
|
||
) : (
|
||
scrollableData
|
||
.slice(
|
||
buffer.visualScrollRow,
|
||
buffer.visualScrollRow + buffer.viewportHeight,
|
||
)
|
||
.map((item, index) => {
|
||
const actualIndex = buffer.visualScrollRow + index;
|
||
const key =
|
||
item.type === 'visualLine'
|
||
? `line-${item.absoluteVisualIdx}`
|
||
: `ghost-${item.index}`;
|
||
return (
|
||
<Fragment key={key}>
|
||
{renderItem({ item, index: actualIndex })}
|
||
</Fragment>
|
||
);
|
||
})
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</HalfLinePaddedBox>
|
||
{useLineFallback || !useBackgroundColor ? (
|
||
<Box
|
||
borderStyle="round"
|
||
borderTop={false}
|
||
borderBottom={true}
|
||
borderLeft={false}
|
||
borderRight={false}
|
||
borderColor={borderColor}
|
||
width={terminalWidth}
|
||
flexDirection="row"
|
||
alignItems="flex-start"
|
||
height={0}
|
||
/>
|
||
) : null}
|
||
{suggestionsPosition === 'below' && suggestionsNode}
|
||
</>
|
||
);
|
||
};
|