diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 106 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.tsx | 51 |
2 files changed, 85 insertions, 72 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index c7ed9a81..c022fb31 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -11,6 +11,7 @@ import { measureElement, Static, Text, + useStdin, useInput, type Key as InkKeyType, } from 'ink'; @@ -54,8 +55,10 @@ import { useLogger } from './hooks/useLogger.js'; import { StreamingContext } from './contexts/StreamingContext.js'; import { SessionStatsProvider } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { useTextBuffer } from './components/shared/text-buffer.js'; +import * as fs from 'fs'; -const CTRL_C_PROMPT_DURATION_MS = 1000; +const CTRL_EXIT_PROMPT_DURATION_MS = 1000; interface AppProps { config: Config; @@ -98,6 +101,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { HistoryItem[] | null >(null); const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null); + const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); + const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null); const errorCount = useMemo( () => consoleMessages.filter((msg) => msg.type === 'error').length, @@ -181,54 +186,84 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { setQuittingMessages, ); + const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); + const { stdin, setRawMode } = useStdin(); + const isValidPath = useCallback((filePath: string): boolean => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch (_e) { + return false; + } + }, []); + + const widthFraction = 0.9; + const inputWidth = Math.max( + 20, + Math.round(terminalWidth * widthFraction) - 3, + ); + const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + + const buffer = useTextBuffer({ + initialText: '', + viewport: { height: 10, width: inputWidth }, + stdin, + setRawMode, + isValidPath, + }); + + const handleExit = useCallback( + ( + pressedOnce: boolean, + setPressedOnce: (value: boolean) => void, + timerRef: React.MutableRefObject<NodeJS.Timeout | null>, + ) => { + if (pressedOnce) { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + const quitCommand = slashCommands.find( + (cmd) => cmd.name === 'quit' || cmd.altName === 'exit', + ); + if (quitCommand) { + quitCommand.action('quit', '', ''); + } else { + process.exit(0); + } + } else { + setPressedOnce(true); + timerRef.current = setTimeout(() => { + setPressedOnce(false); + timerRef.current = null; + }, CTRL_EXIT_PROMPT_DURATION_MS); + } + }, + [slashCommands], + ); + useInput((input: string, key: InkKeyType) => { if (key.ctrl && input === 'o') { setShowErrorDetails((prev) => !prev); refreshStatic(); } else if (key.ctrl && input === 't') { - // Toggle showing tool descriptions const newValue = !showToolDescriptions; setShowToolDescriptions(newValue); refreshStatic(); - // Re-execute the MCP command to show/hide descriptions const mcpServers = config.getMcpServers(); if (Object.keys(mcpServers || {}).length > 0) { - // Pass description flag based on the new value handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); } } else if (key.ctrl && (input === 'c' || input === 'C')) { - if (ctrlCPressedOnce) { - if (ctrlCTimerRef.current) { - clearTimeout(ctrlCTimerRef.current); - } - const quitCommand = slashCommands.find( - (cmd) => cmd.name === 'quit' || cmd.altName === 'exit', - ); - if (quitCommand) { - quitCommand.action('quit', '', ''); - } else { - process.exit(0); - } - } else { - setCtrlCPressedOnce(true); - ctrlCTimerRef.current = setTimeout(() => { - setCtrlCPressedOnce(false); - ctrlCTimerRef.current = null; - }, CTRL_C_PROMPT_DURATION_MS); + handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); + } else if (key.ctrl && (input === 'd' || input === 'D')) { + if (buffer.text.length > 0) { + // Do nothing if there is text in the input. + return; } + handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); } }); - useEffect( - () => () => { - if (ctrlCTimerRef.current) { - clearTimeout(ctrlCTimerRef.current); - } - }, - [], - ); - useConsolePatcher({ onNewMessage: handleNewMessage, debugMode: config.getDebugMode(), @@ -324,7 +359,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { refreshStatic(); }, [clearItems, clearConsoleMessagesState, refreshStatic]); - const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); // Get terminalWidth const mainControlsRef = useRef<DOMElement>(null); const pendingHistoryItemRef = useRef<DOMElement>(null); @@ -514,6 +548,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { <Text color={Colors.AccentYellow}> Press Ctrl+C again to exit. </Text> + ) : ctrlDPressedOnce ? ( + <Text color={Colors.AccentYellow}> + Press Ctrl+D again to exit. + </Text> ) : ( <ContextSummaryDisplay geminiMdFileCount={geminiMdFileCount} @@ -540,7 +578,9 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { {isInputActive && ( <InputPrompt - widthFraction={0.9} + buffer={buffer} + inputWidth={inputWidth} + suggestionsWidth={suggestionsWidth} onSubmit={handleFinalSubmit} userMessages={userMessages} onClearScreen={handleClearScreen} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 534d7112..c4177c00 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -4,15 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'fs'; import React, { useCallback, useEffect, useState } from 'react'; -import { Text, Box, useInput, useStdin } from 'ink'; +import { Text, Box, useInput } from 'ink'; import { Colors } from '../colors.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; -import { useTextBuffer, cpSlice, cpLen } from './shared/text-buffer.js'; +import { cpSlice, cpLen, TextBuffer } from './shared/text-buffer.js'; import chalk from 'chalk'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; import stringWidth from 'string-width'; import process from 'node:process'; import { useCompletion } from '../hooks/useCompletion.js'; @@ -21,60 +19,36 @@ import { SlashCommand } from '../hooks/slashCommandProcessor.js'; import { Config } from '@gemini-cli/core'; export interface InputPromptProps { + buffer: TextBuffer; onSubmit: (value: string) => void; userMessages: readonly string[]; onClearScreen: () => void; config: Config; // Added config for useCompletion slashCommands: SlashCommand[]; // Added slashCommands for useCompletion placeholder?: string; - height?: number; // Visible height of the editor area focus?: boolean; - widthFraction: number; + inputWidth: number; + suggestionsWidth: number; shellModeActive: boolean; setShellModeActive: (value: boolean) => void; } export const InputPrompt: React.FC<InputPromptProps> = ({ + buffer, onSubmit, userMessages, onClearScreen, config, slashCommands, placeholder = ' Type your message or @path/to/file', - height = 10, focus = true, - widthFraction, + inputWidth, + suggestionsWidth, shellModeActive, setShellModeActive, }) => { - const terminalSize = useTerminalSize(); - const padding = 3; - const effectiveWidth = Math.max( - 20, - Math.round(terminalSize.columns * widthFraction) - padding, - ); - const suggestionsWidth = Math.max(60, Math.floor(terminalSize.columns * 0.8)); - const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); - const { stdin, setRawMode } = useStdin(); - - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); - - const buffer = useTextBuffer({ - initialText: '', - viewport: { height, width: effectiveWidth }, - stdin, - setRawMode, - isValidPath, - }); - const completion = useCompletion( buffer.text, config.getTargetDir(), @@ -370,11 +344,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ ) : ( linesToRender.map((lineText, visualIdxInRenderedSet) => { const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; - let display = cpSlice(lineText, 0, effectiveWidth); + let display = cpSlice(lineText, 0, inputWidth); const currentVisualWidth = stringWidth(display); - if (currentVisualWidth < effectiveWidth) { - display = - display + ' '.repeat(effectiveWidth - currentVisualWidth); + if (currentVisualWidth < inputWidth) { + display = display + ' '.repeat(inputWidth - currentVisualWidth); } if (visualIdxInRenderedSet === cursorVisualRow) { @@ -394,7 +367,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ cpSlice(display, relativeVisualColForHighlight + 1); } else if ( relativeVisualColForHighlight === cpLen(display) && - cpLen(display) === effectiveWidth + cpLen(display) === inputWidth ) { display = display + chalk.inverse(' '); } |
