From daceb9963f8962051628127c661297fbd95b4a1d Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 13 Jun 2025 09:59:09 -0700 Subject: feat(cli): support ctrl+d to exit (#878) Similar to ctrl+c, ctrl+d can now be used to exit the program. To avoid accidental exit, ctrl+d must be pressed twice in relatively quick succession (same as ctrl+c). Following common UX pattern, ctrl+d will be ignored when the input prompt is non-empty. This behavior is similar to how most shell (bash/zsh) behaves. To support this, I had to refactor so that text buffer is initialized outside of the InputPrompt component and instead do it on the main App component to allow input controller to have access to check the content of the text buffer. --- packages/cli/src/ui/App.tsx | 106 +++++++++++++++++-------- packages/cli/src/ui/components/InputPrompt.tsx | 51 +++--------- 2 files changed, 85 insertions(+), 72 deletions(-) (limited to 'packages/cli/src') 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(null); + const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); + const ctrlDTimerRef = useRef(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, + ) => { + 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(null); const pendingHistoryItemRef = useRef(null); @@ -514,6 +548,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { Press Ctrl+C again to exit. + ) : ctrlDPressedOnce ? ( + + Press Ctrl+D again to exit. + ) : ( { {isInputActive && ( 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 = ({ + 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 = ({ ) : ( 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 = ({ cpSlice(display, relativeVisualColForHighlight + 1); } else if ( relativeVisualColForHighlight === cpLen(display) && - cpLen(display) === effectiveWidth + cpLen(display) === inputWidth ) { display = display + chalk.inverse(' '); } -- cgit v1.2.3