diff options
| author | Sijie Wang <[email protected]> | 2025-07-25 15:36:42 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-25 22:36:42 +0000 |
| commit | fbdc8d5ab3f76aef32af6a8f516d97771c56a7ac (patch) | |
| tree | 2167cd9ab4c5a1378d466e735bc41e167ea4d904 /packages/cli/src/ui/App.tsx | |
| parent | aa71438684dd0350acf62fc01d1e6244fd4d3f51 (diff) | |
Vim mode (#3936)
Diffstat (limited to 'packages/cli/src/ui/App.tsx')
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 162 |
1 files changed, 94 insertions, 68 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index bd99f01b..da01521b 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -73,6 +73,8 @@ import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; +import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js'; +import { useVim } from './hooks/vim.js'; import * as fs from 'fs'; import { UpdateNotification } from './components/UpdateNotification.js'; import { @@ -97,7 +99,9 @@ interface AppProps { export const AppWrapper = (props: AppProps) => ( <SessionStatsProvider> - <App {...props} /> + <VimModeProvider settings={props.settings}> + <App {...props} /> + </VimModeProvider> </SessionStatsProvider> ); @@ -374,6 +378,49 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { config.setFlashFallbackHandler(flashFallbackHandler); }, [config, addItem, userTier]); + // Terminal and UI setup + const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); + const { stdin, setRawMode } = useStdin(); + const isInitialMount = useRef(true); + + const widthFraction = 0.9; + const inputWidth = Math.max( + 20, + Math.floor(terminalWidth * widthFraction) - 3, + ); + const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + + // Utility callbacks + const isValidPath = useCallback((filePath: string): boolean => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch (_e) { + return false; + } + }, []); + + const getPreferredEditor = useCallback(() => { + const editorType = settings.merged.preferredEditor; + const isValidEditor = isEditorAvailable(editorType); + if (!isValidEditor) { + openEditorDialog(); + return; + } + return editorType as EditorType; + }, [settings, openEditorDialog]); + + const onAuthError = useCallback(() => { + setAuthError('reauth required'); + openAuthDialog(); + }, [openAuthDialog, setAuthError]); + + // Core hooks and processors + const { + vimEnabled: vimModeEnabled, + vimMode, + toggleVimEnabled, + } = useVimMode(); + const { handleSlashCommand, slashCommands, @@ -394,26 +441,41 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { toggleCorgiMode, setQuittingMessages, openPrivacyNotice, + toggleVimEnabled, ); - const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; - const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); - const isInitialMount = useRef(true); - const { stdin, setRawMode } = useStdin(); - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); + const { + streamingState, + submitQuery, + initError, + pendingHistoryItems: pendingGeminiHistoryItems, + thought, + } = useGeminiStream( + config.getGeminiClient(), + history, + addItem, + setShowHelp, + config, + setDebugMessage, + handleSlashCommand, + shellModeActive, + getPreferredEditor, + onAuthError, + performMemoryRefresh, + modelSwitchedFromQuotaError, + setModelSwitchedFromQuotaError, + ); - const widthFraction = 0.9; - const inputWidth = Math.max( - 20, - Math.floor(terminalWidth * widthFraction) - 3, + // Input handling + const handleFinalSubmit = useCallback( + (submittedValue: string) => { + const trimmedValue = submittedValue.trim(); + if (trimmedValue.length > 0) { + submitQuery(trimmedValue); + } + }, + [submitQuery], ); - const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); const buffer = useTextBuffer({ initialText: '', @@ -424,6 +486,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { shellModeActive, }); + const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); + const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; + pendingHistoryItems.push(...pendingGeminiHistoryItems); + + const { elapsedTime, currentLoadingPhrase } = + useLoadingIndicator(streamingState); + const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); + const handleExit = useCallback( ( pressedOnce: boolean, @@ -489,57 +559,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [config]); - const getPreferredEditor = useCallback(() => { - const editorType = settings.merged.preferredEditor; - const isValidEditor = isEditorAvailable(editorType); - if (!isValidEditor) { - openEditorDialog(); - return; - } - return editorType as EditorType; - }, [settings, openEditorDialog]); - - const onAuthError = useCallback(() => { - setAuthError('reauth required'); - openAuthDialog(); - }, [openAuthDialog, setAuthError]); - - const { - streamingState, - submitQuery, - initError, - pendingHistoryItems: pendingGeminiHistoryItems, - thought, - } = useGeminiStream( - config.getGeminiClient(), - history, - addItem, - setShowHelp, - config, - setDebugMessage, - handleSlashCommand, - shellModeActive, - getPreferredEditor, - onAuthError, - performMemoryRefresh, - modelSwitchedFromQuotaError, - setModelSwitchedFromQuotaError, - ); - pendingHistoryItems.push(...pendingGeminiHistoryItems); - const { elapsedTime, currentLoadingPhrase } = - useLoadingIndicator(streamingState); - const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); - - const handleFinalSubmit = useCallback( - (submittedValue: string) => { - const trimmedValue = submittedValue.trim(); - if (trimmedValue.length > 0) { - submitQuery(trimmedValue); - } - }, - [submitQuery], - ); - const logger = useLogger(); const [userMessages, setUserMessages] = useState<string[]>([]); @@ -697,6 +716,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Arbitrary threshold to ensure that items in the static area are large // enough but not too large to make the terminal hard to use. const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); + const placeholder = vimModeEnabled + ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." + : ' Type your message or @path/to/file'; + return ( <StreamingContext.Provider value={streamingState}> <Box flexDirection="column" marginBottom={1} width="90%"> @@ -938,6 +961,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { shellModeActive={shellModeActive} setShellModeActive={setShellModeActive} focus={isFocused} + vimHandleInput={vimHandleInput} + placeholder={placeholder} /> )} </> @@ -989,6 +1014,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } promptTokenCount={sessionStats.lastPromptTokenCount} nightly={nightly} + vimMode={vimModeEnabled ? vimMode : undefined} /> </Box> </Box> |
