summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/App.tsx
diff options
context:
space:
mode:
authorSijie Wang <[email protected]>2025-07-25 15:36:42 -0700
committerGitHub <[email protected]>2025-07-25 22:36:42 +0000
commitfbdc8d5ab3f76aef32af6a8f516d97771c56a7ac (patch)
tree2167cd9ab4c5a1378d466e735bc41e167ea4d904 /packages/cli/src/ui/App.tsx
parentaa71438684dd0350acf62fc01d1e6244fd4d3f51 (diff)
Vim mode (#3936)
Diffstat (limited to 'packages/cli/src/ui/App.tsx')
-rw-r--r--packages/cli/src/ui/App.tsx162
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>