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,26 +186,40 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { setQuittingMessages, ); - 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(); + 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; + } + }, []); - // 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 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', @@ -211,24 +230,40 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { process.exit(0); } } else { - setCtrlCPressedOnce(true); - ctrlCTimerRef.current = setTimeout(() => { - setCtrlCPressedOnce(false); - ctrlCTimerRef.current = null; - }, CTRL_C_PROMPT_DURATION_MS); - } - } - }); - - useEffect( - () => () => { - if (ctrlCTimerRef.current) { - clearTimeout(ctrlCTimerRef.current); + 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') { + const newValue = !showToolDescriptions; + setShowToolDescriptions(newValue); + refreshStatic(); + + const mcpServers = config.getMcpServers(); + if (Object.keys(mcpServers || {}).length > 0) { + handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); + } + } else if (key.ctrl && (input === 'c' || input === 'C')) { + 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); + } + }); + 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(' '); }