Files
gemini-cli/packages/cli/src/ui/components/InputPrompt.tsx
T

1914 lines
60 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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}>
&gt; 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}
</>
);
};