mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Add 'Fine' and 'Dismiss' options to feedback dialogs that allow temporary dismissal without permanently closing the feedback request. Only numerical ratings (0, 1, 2, 3) will permanently close feedback dialogs, while all other inputs result in temporary dismissal with persistent re-prompting. This ensures feedback collection reliability while respecting user workflow by allowing users to temporarily dismiss prompts when busy and providing feedback when ready. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
931 lines
29 KiB
TypeScript
931 lines
29 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
|
import { theme } from '../semantic-colors.js';
|
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
|
import type { TextBuffer } from './shared/text-buffer.js';
|
|
import { logicalPosToOffset } from './shared/text-buffer.js';
|
|
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
|
import chalk from 'chalk';
|
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
|
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
|
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
|
import type { Key } from '../hooks/useKeypress.js';
|
|
import { useKeypress } from '../hooks/useKeypress.js';
|
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
|
import type { Config } from '@qwen-code/qwen-code-core';
|
|
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
|
import {
|
|
parseInputForHighlighting,
|
|
buildSegmentsForVisualSlice,
|
|
} from '../utils/highlight.js';
|
|
import { t } from '../../i18n/index.js';
|
|
import {
|
|
clipboardHasImage,
|
|
saveClipboardImage,
|
|
cleanupOldClipboardImages,
|
|
} from '../utils/clipboardUtils.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 { useUIActions } from '../contexts/UIActionsContext.js';
|
|
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
|
export interface InputPromptProps {
|
|
buffer: TextBuffer;
|
|
onSubmit: (value: string) => void;
|
|
userMessages: readonly string[];
|
|
onClearScreen: () => void;
|
|
config: Config;
|
|
slashCommands: readonly SlashCommand[];
|
|
commandContext: CommandContext;
|
|
placeholder?: string;
|
|
focus?: boolean;
|
|
inputWidth: number;
|
|
suggestionsWidth: number;
|
|
shellModeActive: boolean;
|
|
setShellModeActive: (value: boolean) => void;
|
|
approvalMode: ApprovalMode;
|
|
onEscapePromptChange?: (showPrompt: boolean) => void;
|
|
onToggleShortcuts?: () => void;
|
|
showShortcuts?: boolean;
|
|
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
|
vimHandleInput?: (key: Key) => boolean;
|
|
isEmbeddedShellFocused?: boolean;
|
|
}
|
|
|
|
// The input content, input container, and input suggestions list may have different widths
|
|
export const calculatePromptWidths = (terminalWidth: number) => {
|
|
const widthFraction = 0.9;
|
|
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
|
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
|
|
const MIN_CONTENT_WIDTH = 2;
|
|
|
|
const innerContentWidth =
|
|
Math.floor(terminalWidth * widthFraction) -
|
|
FRAME_PADDING_AND_BORDER -
|
|
PROMPT_PREFIX_WIDTH;
|
|
|
|
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
|
|
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
|
|
const containerWidth = inputWidth + FRAME_OVERHEAD;
|
|
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
|
|
|
|
return {
|
|
inputWidth,
|
|
containerWidth,
|
|
suggestionsWidth,
|
|
frameOverhead: FRAME_OVERHEAD,
|
|
} as const;
|
|
};
|
|
|
|
export const InputPrompt: React.FC<InputPromptProps> = ({
|
|
buffer,
|
|
onSubmit,
|
|
userMessages,
|
|
onClearScreen,
|
|
config,
|
|
slashCommands,
|
|
commandContext,
|
|
placeholder,
|
|
focus = true,
|
|
suggestionsWidth,
|
|
shellModeActive,
|
|
setShellModeActive,
|
|
approvalMode,
|
|
onEscapePromptChange,
|
|
onToggleShortcuts,
|
|
showShortcuts,
|
|
onSuggestionsVisibilityChange,
|
|
vimHandleInput,
|
|
isEmbeddedShellFocused,
|
|
}) => {
|
|
const isShellFocused = useShellFocusState();
|
|
const uiState = useUIState();
|
|
const uiActions = useUIActions();
|
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
|
const [escPressCount, setEscPressCount] = useState(0);
|
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
|
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
|
|
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const [dirs, setDirs] = useState<readonly string[]>(
|
|
config.getWorkspaceContext().getDirectories(),
|
|
);
|
|
const dirsChanged = config.getWorkspaceContext().getDirectories();
|
|
useEffect(() => {
|
|
if (dirs.length !== dirsChanged.length) {
|
|
setDirs(dirsChanged);
|
|
}
|
|
}, [dirs.length, dirsChanged]);
|
|
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
|
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
|
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
|
|
0, 0,
|
|
]);
|
|
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
|
|
useState<number>(-1);
|
|
const shellHistory = useShellHistory(config.getProjectRoot());
|
|
const shellHistoryData = shellHistory.history;
|
|
|
|
const completion = useCommandCompletion(
|
|
buffer,
|
|
dirs,
|
|
config.getTargetDir(),
|
|
slashCommands,
|
|
commandContext,
|
|
reverseSearchActive,
|
|
config,
|
|
// Suppress completion when history navigation just occurred
|
|
!justNavigatedHistory,
|
|
);
|
|
|
|
const reverseSearchCompletion = useReverseSearchCompletion(
|
|
buffer,
|
|
shellHistoryData,
|
|
reverseSearchActive,
|
|
);
|
|
|
|
const commandSearchCompletion = useReverseSearchCompletion(
|
|
buffer,
|
|
userMessages,
|
|
commandSearchActive,
|
|
);
|
|
|
|
const resetCompletionState = completion.resetCompletionState;
|
|
const resetReverseSearchCompletionState =
|
|
reverseSearchCompletion.resetCompletionState;
|
|
const resetCommandSearchCompletionState =
|
|
commandSearchCompletion.resetCompletionState;
|
|
|
|
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
|
|
|
const resetEscapeState = useCallback(() => {
|
|
if (escapeTimerRef.current) {
|
|
clearTimeout(escapeTimerRef.current);
|
|
escapeTimerRef.current = null;
|
|
}
|
|
setEscPressCount(0);
|
|
setShowEscapePrompt(false);
|
|
}, []);
|
|
|
|
// Notify parent component about escape prompt state changes
|
|
useEffect(() => {
|
|
if (onEscapePromptChange) {
|
|
onEscapePromptChange(showEscapePrompt);
|
|
}
|
|
}, [showEscapePrompt, onEscapePromptChange]);
|
|
|
|
// Clear escape prompt timer on unmount
|
|
useEffect(
|
|
() => () => {
|
|
if (escapeTimerRef.current) {
|
|
clearTimeout(escapeTimerRef.current);
|
|
}
|
|
if (pasteTimeoutRef.current) {
|
|
clearTimeout(pasteTimeoutRef.current);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleSubmitAndClear = useCallback(
|
|
(submittedValue: string) => {
|
|
if (shellModeActive) {
|
|
shellHistory.addCommandToHistory(submittedValue);
|
|
}
|
|
// 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('');
|
|
onSubmit(submittedValue);
|
|
resetCompletionState();
|
|
resetReverseSearchCompletionState();
|
|
},
|
|
[
|
|
onSubmit,
|
|
buffer,
|
|
resetCompletionState,
|
|
shellModeActive,
|
|
shellHistory,
|
|
resetReverseSearchCompletionState,
|
|
],
|
|
);
|
|
|
|
const customSetTextAndResetCompletionSignal = useCallback(
|
|
(newText: string) => {
|
|
buffer.setText(newText);
|
|
setJustNavigatedHistory(true);
|
|
},
|
|
[buffer, setJustNavigatedHistory],
|
|
);
|
|
|
|
const inputHistory = useInputHistory({
|
|
userMessages,
|
|
onSubmit: handleSubmitAndClear,
|
|
// History navigation (Ctrl+P/N) now always works since completion navigation
|
|
// only uses arrow keys. Only disable in shell mode.
|
|
isActive: !shellModeActive,
|
|
currentQuery: buffer.text,
|
|
onChange: customSetTextAndResetCompletionSignal,
|
|
});
|
|
|
|
// Effect to reset completion if history navigation just occurred and set the text
|
|
useEffect(() => {
|
|
if (justNavigatedHistory) {
|
|
resetCompletionState();
|
|
resetReverseSearchCompletionState();
|
|
resetCommandSearchCompletionState();
|
|
setExpandedSuggestionIndex(-1);
|
|
setJustNavigatedHistory(false);
|
|
}
|
|
}, [
|
|
justNavigatedHistory,
|
|
buffer.text,
|
|
resetCompletionState,
|
|
setJustNavigatedHistory,
|
|
resetReverseSearchCompletionState,
|
|
resetCommandSearchCompletionState,
|
|
]);
|
|
|
|
// Handle clipboard image pasting with Ctrl+V
|
|
const handleClipboardImage = useCallback(async () => {
|
|
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 [row, col] = buffer.cursor;
|
|
|
|
// Calculate offset from row/col
|
|
let offset = 0;
|
|
for (let i = 0; i < row; i++) {
|
|
offset += buffer.lines[i].length + 1; // +1 for newline
|
|
}
|
|
offset += col;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error handling clipboard image:', error);
|
|
}
|
|
}, [buffer, config]);
|
|
|
|
const handleInput = useCallback(
|
|
(key: Key) => {
|
|
// TODO(jacobr): this special case is likely not needed anymore.
|
|
// We should probably stop supporting paste if the InputPrompt is not
|
|
// focused.
|
|
/// We want to handle paste even when not focused to support drag and drop.
|
|
if (!focus && !key.paste) {
|
|
return;
|
|
}
|
|
|
|
if (key.paste) {
|
|
// Record paste time to prevent accidental auto-submission
|
|
setRecentPasteTime(Date.now());
|
|
|
|
// Clear any existing paste timeout
|
|
if (pasteTimeoutRef.current) {
|
|
clearTimeout(pasteTimeoutRef.current);
|
|
}
|
|
|
|
// Clear the paste protection after a safe delay
|
|
pasteTimeoutRef.current = setTimeout(() => {
|
|
setRecentPasteTime(null);
|
|
pasteTimeoutRef.current = null;
|
|
}, 500);
|
|
|
|
// Ensure we never accidentally interpret paste as regular input.
|
|
buffer.handleInput(key);
|
|
return;
|
|
}
|
|
|
|
if (vimHandleInput && vimHandleInput(key)) {
|
|
return;
|
|
}
|
|
|
|
// Handle feedback dialog keyboard interactions when dialog is open
|
|
if (uiState.isFeedbackDialogOpen) {
|
|
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
|
|
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
|
|
return;
|
|
} else {
|
|
// For any other key, close feedback dialog temporarily and continue with normal processing
|
|
uiActions.temporaryCloseFeedbackDialog();
|
|
// Continue processing the key for normal input handling
|
|
}
|
|
}
|
|
|
|
// Reset ESC count and hide prompt on any non-ESC key
|
|
if (key.name !== 'escape') {
|
|
if (escPressCount > 0 || showEscapePrompt) {
|
|
resetEscapeState();
|
|
}
|
|
}
|
|
|
|
if (
|
|
key.sequence === '!' &&
|
|
buffer.text === '' &&
|
|
!completion.showSuggestions
|
|
) {
|
|
// Hide shortcuts when toggling shell mode
|
|
if (showShortcuts && onToggleShortcuts) {
|
|
onToggleShortcuts();
|
|
}
|
|
setShellModeActive(!shellModeActive);
|
|
buffer.setText(''); // Clear the '!' from input
|
|
return;
|
|
}
|
|
|
|
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
|
if (
|
|
key.sequence === '?' &&
|
|
buffer.text === '' &&
|
|
!completion.showSuggestions &&
|
|
onToggleShortcuts
|
|
) {
|
|
onToggleShortcuts();
|
|
return;
|
|
}
|
|
|
|
// Hide shortcuts on any other key press
|
|
if (showShortcuts && onToggleShortcuts) {
|
|
onToggleShortcuts();
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (commandSearchActive) {
|
|
cancelSearch(
|
|
setCommandSearchActive,
|
|
commandSearchCompletion.resetCompletionState,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (shellModeActive) {
|
|
setShellModeActive(false);
|
|
resetEscapeState();
|
|
return;
|
|
}
|
|
|
|
if (completion.showSuggestions) {
|
|
completion.resetCompletionState();
|
|
setExpandedSuggestionIndex(-1);
|
|
resetEscapeState();
|
|
return;
|
|
}
|
|
|
|
// Handle double ESC for clearing input
|
|
if (escPressCount === 0) {
|
|
if (buffer.text === '') {
|
|
return;
|
|
}
|
|
setEscPressCount(1);
|
|
setShowEscapePrompt(true);
|
|
if (escapeTimerRef.current) {
|
|
clearTimeout(escapeTimerRef.current);
|
|
}
|
|
escapeTimerRef.current = setTimeout(() => {
|
|
resetEscapeState();
|
|
}, 500);
|
|
} else {
|
|
// clear input and immediately reset state
|
|
buffer.setText('');
|
|
resetCompletionState();
|
|
resetEscapeState();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
|
setReverseSearchActive(true);
|
|
setTextBeforeReverseSearch(buffer.text);
|
|
setCursorPosition(buffer.cursor);
|
|
return;
|
|
}
|
|
|
|
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
|
onClearScreen();
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
|
navigateDown();
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
|
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
|
setExpandedSuggestionIndex(-1);
|
|
return;
|
|
}
|
|
}
|
|
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
|
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
|
setExpandedSuggestionIndex(activeSuggestionIndex);
|
|
return;
|
|
}
|
|
}
|
|
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
|
sc.handleAutocomplete(activeSuggestionIndex);
|
|
resetState();
|
|
setActive(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
|
|
const textToSubmit =
|
|
showSuggestions && activeSuggestionIndex > -1
|
|
? suggestions[activeSuggestionIndex].value
|
|
: buffer.text;
|
|
handleSubmitAndClear(textToSubmit);
|
|
resetState();
|
|
setActive(false);
|
|
return;
|
|
}
|
|
|
|
// Prevent up/down from falling through to regular history navigation
|
|
if (
|
|
keyMatchers[Command.NAVIGATION_UP](key) ||
|
|
keyMatchers[Command.NAVIGATION_DOWN](key)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If the command is a perfect match, pressing enter should execute it.
|
|
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
|
handleSubmitAndClear(buffer.text);
|
|
return;
|
|
}
|
|
|
|
if (completion.showSuggestions) {
|
|
if (completion.suggestions.length > 1) {
|
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
|
completion.navigateUp();
|
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
|
completion.navigateDown();
|
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
|
return;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
completion.handleAutocomplete(targetIndex);
|
|
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!shellModeActive) {
|
|
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
|
setCommandSearchActive(true);
|
|
setTextBeforeReverseSearch(buffer.text);
|
|
setCursorPosition(buffer.cursor);
|
|
return;
|
|
}
|
|
|
|
if (keyMatchers[Command.HISTORY_UP](key)) {
|
|
inputHistory.navigateUp();
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
|
inputHistory.navigateDown();
|
|
return;
|
|
}
|
|
// Handle arrow-up/down for history on single-line or at edges
|
|
if (
|
|
keyMatchers[Command.NAVIGATION_UP](key) &&
|
|
(buffer.allVisualLines.length === 1 ||
|
|
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
|
) {
|
|
inputHistory.navigateUp();
|
|
return;
|
|
}
|
|
if (
|
|
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
|
(buffer.allVisualLines.length === 1 ||
|
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
|
) {
|
|
inputHistory.navigateDown();
|
|
return;
|
|
}
|
|
} else {
|
|
// Shell History Navigation
|
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
|
const prevCommand = shellHistory.getPreviousCommand();
|
|
if (prevCommand !== null) buffer.setText(prevCommand);
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
|
const nextCommand = shellHistory.getNextCommand();
|
|
if (nextCommand !== null) buffer.setText(nextCommand);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (keyMatchers[Command.SUBMIT](key)) {
|
|
if (buffer.text.trim()) {
|
|
// Check if a paste operation occurred recently to prevent accidental auto-submission
|
|
if (recentPasteTime !== null) {
|
|
// Paste occurred recently, ignore this submit to prevent auto-execution
|
|
return;
|
|
}
|
|
|
|
const [row, col] = buffer.cursor;
|
|
const line = buffer.lines[row];
|
|
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
|
if (charBefore === '\\') {
|
|
buffer.backspace();
|
|
buffer.newline();
|
|
} else {
|
|
handleSubmitAndClear(buffer.text);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Newline insertion
|
|
if (keyMatchers[Command.NEWLINE](key)) {
|
|
buffer.newline();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+A (Home) / Ctrl+E (End)
|
|
if (keyMatchers[Command.HOME](key)) {
|
|
buffer.move('home');
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.END](key)) {
|
|
buffer.move('end');
|
|
return;
|
|
}
|
|
// Ctrl+C (Clear input)
|
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
|
if (buffer.text.length > 0) {
|
|
buffer.setText('');
|
|
resetCompletionState();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Kill line commands
|
|
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
|
buffer.killLineRight();
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
|
buffer.killLineLeft();
|
|
return;
|
|
}
|
|
|
|
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
|
buffer.deleteWordLeft();
|
|
return;
|
|
}
|
|
|
|
// External editor
|
|
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
|
buffer.openInExternalEditor();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+V for clipboard image paste
|
|
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
|
handleClipboardImage();
|
|
return;
|
|
}
|
|
|
|
// Fall back to the text buffer's default input handling for all other keys
|
|
buffer.handleInput(key);
|
|
},
|
|
[
|
|
focus,
|
|
buffer,
|
|
completion,
|
|
shellModeActive,
|
|
setShellModeActive,
|
|
onClearScreen,
|
|
inputHistory,
|
|
handleSubmitAndClear,
|
|
shellHistory,
|
|
reverseSearchCompletion,
|
|
handleClipboardImage,
|
|
resetCompletionState,
|
|
escPressCount,
|
|
showEscapePrompt,
|
|
resetEscapeState,
|
|
vimHandleInput,
|
|
reverseSearchActive,
|
|
textBeforeReverseSearch,
|
|
cursorPosition,
|
|
recentPasteTime,
|
|
commandSearchActive,
|
|
commandSearchCompletion,
|
|
onToggleShortcuts,
|
|
showShortcuts,
|
|
uiState,
|
|
uiActions,
|
|
],
|
|
);
|
|
|
|
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
|
|
|
const linesToRender = buffer.viewportVisualLines;
|
|
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
|
buffer.visualCursor;
|
|
const scrollVisualRow = buffer.visualScrollRow;
|
|
|
|
const getActiveCompletion = () => {
|
|
if (commandSearchActive) return commandSearchCompletion;
|
|
if (reverseSearchActive) return reverseSearchCompletion;
|
|
return completion;
|
|
};
|
|
|
|
const activeCompletion = getActiveCompletion();
|
|
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
|
|
|
// Notify parent about suggestions visibility changes
|
|
useEffect(() => {
|
|
if (onSuggestionsVisibilityChange) {
|
|
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
|
}
|
|
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
|
|
|
const showAutoAcceptStyling =
|
|
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
|
const showYoloStyling =
|
|
!shellModeActive && approvalMode === ApprovalMode.YOLO;
|
|
|
|
let statusColor: string | undefined;
|
|
let statusText = '';
|
|
if (shellModeActive) {
|
|
statusColor = theme.ui.symbol;
|
|
statusText = t('Shell mode');
|
|
} else if (showYoloStyling) {
|
|
statusColor = theme.status.errorDim;
|
|
statusText = t('YOLO mode');
|
|
} else if (showAutoAcceptStyling) {
|
|
statusColor = theme.status.warningDim;
|
|
statusText = t('Accepting edits');
|
|
}
|
|
|
|
const borderColor =
|
|
isShellFocused && !isEmbeddedShellFocused
|
|
? (statusColor ?? theme.border.focused)
|
|
: theme.border.default;
|
|
|
|
return (
|
|
<>
|
|
<Box
|
|
borderStyle="single"
|
|
borderTop={true}
|
|
borderBottom={true}
|
|
borderLeft={false}
|
|
borderRight={false}
|
|
borderColor={borderColor}
|
|
>
|
|
<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">
|
|
{buffer.text.length === 0 && placeholder ? (
|
|
showCursor ? (
|
|
<Text>
|
|
{chalk.inverse(placeholder.slice(0, 1))}
|
|
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
|
</Text>
|
|
) : (
|
|
<Text color={theme.text.secondary}>{placeholder}</Text>
|
|
)
|
|
) : (
|
|
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
|
const absoluteVisualIdx =
|
|
scrollVisualRow + visualIdxInRenderedSet;
|
|
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
|
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
|
const isOnCursorLine =
|
|
focus && visualIdxInRenderedSet === cursorVisualRow;
|
|
|
|
const renderedLine: React.ReactNode[] = [];
|
|
|
|
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
|
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
|
const tokens = parseInputForHighlighting(
|
|
logicalLine,
|
|
logicalLineIdx,
|
|
);
|
|
|
|
const visualStart = logicalStartCol;
|
|
const visualEnd = logicalStartCol + cpLen(lineText);
|
|
const segments = buildSegmentsForVisualSlice(
|
|
tokens,
|
|
visualStart,
|
|
visualEnd,
|
|
);
|
|
|
|
let charCount = 0;
|
|
segments.forEach((seg, segIdx) => {
|
|
const segLen = cpLen(seg.text);
|
|
let display = seg.text;
|
|
|
|
if (isOnCursorLine) {
|
|
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
|
const segStart = charCount;
|
|
const segEnd = segStart + segLen;
|
|
if (
|
|
relativeVisualColForHighlight >= segStart &&
|
|
relativeVisualColForHighlight < segEnd
|
|
) {
|
|
const charToHighlight = cpSlice(
|
|
seg.text,
|
|
relativeVisualColForHighlight - segStart,
|
|
relativeVisualColForHighlight - segStart + 1,
|
|
);
|
|
const highlighted = showCursor
|
|
? chalk.inverse(charToHighlight)
|
|
: charToHighlight;
|
|
display =
|
|
cpSlice(
|
|
seg.text,
|
|
0,
|
|
relativeVisualColForHighlight - segStart,
|
|
) +
|
|
highlighted +
|
|
cpSlice(
|
|
seg.text,
|
|
relativeVisualColForHighlight - segStart + 1,
|
|
);
|
|
}
|
|
charCount = segEnd;
|
|
}
|
|
|
|
const color =
|
|
seg.type === 'command' || seg.type === 'file'
|
|
? theme.text.accent
|
|
: theme.text.primary;
|
|
|
|
renderedLine.push(
|
|
<Text key={`token-${segIdx}`} color={color}>
|
|
{display}
|
|
</Text>,
|
|
);
|
|
});
|
|
|
|
if (
|
|
isOnCursorLine &&
|
|
cursorVisualColAbsolute === cpLen(lineText)
|
|
) {
|
|
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
|
renderedLine.push(
|
|
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
|
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
|
</Text>,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
|
<Text>{renderedLine}</Text>
|
|
</Box>
|
|
);
|
|
})
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
{shouldShowSuggestions && (
|
|
<Box marginLeft={2} marginRight={2}>
|
|
<SuggestionsDisplay
|
|
suggestions={activeCompletion.suggestions}
|
|
activeIndex={activeCompletion.activeSuggestionIndex}
|
|
isLoading={activeCompletion.isLoadingSuggestions}
|
|
width={suggestionsWidth}
|
|
scrollOffset={activeCompletion.visibleStartIndex}
|
|
userInput={buffer.text}
|
|
mode={
|
|
buffer.text.startsWith('/') &&
|
|
!reverseSearchActive &&
|
|
!commandSearchActive
|
|
? 'slash'
|
|
: 'reverse'
|
|
}
|
|
expandedIndex={expandedSuggestionIndex}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</>
|
|
);
|
|
};
|