From 17b40b31b867aec5a4031b8621568a20a5b67f60 Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Fri, 27 Mar 2026 16:43:21 +0000 Subject: [PATCH] feat(cli): add UI integration for /btw command --- packages/cli/src/test-utils/render.tsx | 8 ++ packages/cli/src/ui/AppContainer.tsx | 39 ++++++++- packages/cli/src/ui/components/BtwDisplay.tsx | 86 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 16 ++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 7 ++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 10 +++ 7 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/components/BtwDisplay.tsx diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index bf8ca468eb..27cd660961 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -523,6 +523,13 @@ const baseMockUiState = { }, hintMode: false, hintBuffer: '', + btwState: { + isActive: false, + query: '', + response: '', + isStreaming: false, + error: null, + }, bannerData: { defaultText: '', warningText: '', @@ -589,6 +596,7 @@ const mockUIActions: UIActions = { dismissBackgroundTask: vi.fn(), setActiveBackgroundTaskPid: vi.fn(), setIsBackgroundTaskListOpen: vi.fn(), + dismissBtw: vi.fn(), setAuthContext: vi.fn(), onHintInput: vi.fn(), onHintBackspace: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e61cada6b5..e6c5ca85d0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -13,6 +13,7 @@ import { useLayoutEffect, useContext, } from 'react'; +import { type PartListUnion } from '@google/genai'; import { type DOMElement, ResizeObserver, @@ -91,6 +92,7 @@ import { ApiKeyUpdatedEvent, type InjectionSource, startMemoryService, + partListUnionToString, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -156,6 +158,7 @@ import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; +import { useBtw } from './hooks/useBtw.js'; import { useBanner } from './hooks/useBanner.js'; import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; @@ -225,6 +228,7 @@ export const AppContainer = (props: AppContainerProps) => { const historyManager = useHistory({ chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), }); + const btw = useBtw(config.getGeminiClient()); useMemoryMonitor(historyManager); const isAlternateBuffer = config.getUseAlternateBuffer(); @@ -1026,6 +1030,21 @@ Logging in with Google... Restarting Gemini CLI to continue. setCustomDialog, ); + const handleSlashCommandWrapper = useCallback( + async (cmd: PartListUnion) => { + const submittedValue = partListUnionToString(cmd); + const result = await handleSlashCommand(submittedValue); + if (result && result.type === 'btw') { + void btw.submitBtw(result.query); + return { + type: 'handled' as const, + }; + } + return result; + }, + [handleSlashCommand, btw], + ); + const [authConsentRequest, setAuthConsentRequest] = useState(null); const [permissionConfirmationRequest, setPermissionConfirmationRequest] = @@ -1187,7 +1206,7 @@ Logging in with Google... Restarting Gemini CLI to continue. config, settings, setDebugMessage, - handleSlashCommand, + handleSlashCommandWrapper, shellModeActive, getPreferredEditor, onAuthError, @@ -1355,7 +1374,7 @@ Logging in with Google... Restarting Gemini CLI to continue. slashCommands ?? [], ); if (commandToExecute?.isSafeConcurrent) { - void handleSlashCommand(submittedValue); + void handleSlashCommandWrapper(submittedValue); addInput(submittedValue); return; } @@ -1407,7 +1426,7 @@ Logging in with Google... Restarting Gemini CLI to continue. addMessage, addInput, submitQuery, - handleSlashCommand, + handleSlashCommandWrapper, slashCommands, isMcpReady, streamingState, @@ -2491,6 +2510,13 @@ Logging in with Google... Restarting Gemini CLI to continue. hintMode: config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems), hintBuffer: '', + btwState: { + isActive: btw.isActive, + query: btw.query, + response: btw.response, + isStreaming: btw.isStreaming, + error: btw.error, + }, }), [ isThemeDialogOpen, @@ -2608,6 +2634,11 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged, newAgents, showIsExpandableHint, + btw.isActive, + btw.query, + btw.response, + btw.isStreaming, + btw.error, ], ); @@ -2667,6 +2698,7 @@ Logging in with Google... Restarting Gemini CLI to continue. dismissBackgroundTask, setActiveBackgroundTaskPid, setIsBackgroundTaskListOpen, + dismissBtw: btw.dismissBtw, setAuthContext, onHintInput: () => {}, onHintBackspace: () => {}, @@ -2761,6 +2793,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setIsBackgroundTaskListOpen, setAuthContext, setAccountSuspensionInfo, + btw.dismissBtw, newAgents, config, historyManager, diff --git a/packages/cli/src/ui/components/BtwDisplay.tsx b/packages/cli/src/ui/components/BtwDisplay.tsx new file mode 100644 index 0000000000..dd93335bd1 --- /dev/null +++ b/packages/cli/src/ui/components/BtwDisplay.tsx @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; + +interface BtwDisplayProps { + query: string; + response: string; + isStreaming: boolean; + error: string | null; + terminalWidth: number; +} + +export const BtwDisplay: React.FC = ({ + query, + response, + isStreaming, + error, + terminalWidth, +}) => { + const { renderMarkdown } = useUIState(); + + if (!query) return null; + + return ( + + + + + BY THE WAY + + + + + Press Esc, Enter or Space to dismiss + + + + + + Q: + + {query} + + + + + {error ? ( + {error} + ) : ( + + + + + + )} + + + {isStreaming && ( + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b36de8ebb0..64dba15561 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -240,6 +240,7 @@ export const InputPrompt: React.FC = ({ setEmbeddedShellFocused, setShortcutsHelpVisible, toggleCleanUiDetailsVisible, + dismissBtw, } = useUIActions(); const { terminalWidth, @@ -248,6 +249,7 @@ export const InputPrompt: React.FC = ({ backgroundTasks, backgroundTaskHeight, shortcutsHelpVisible, + btwState, } = useUIState(); const [suppressCompletion, setSuppressCompletion] = useState(false); const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } = @@ -682,6 +684,18 @@ export const InputPrompt: React.FC = ({ keyMatchers[Command.COLLAPSE_SUGGESTION](key) || keyMatchers[Command.ACCEPT_SUGGESTION](key)); + // Handle BTW dismissal + if (btwState.isActive) { + if ( + keyMatchers[Command.ESCAPE](key) || + keyMatchers[Command.SUBMIT](key) || + (key.sequence === ' ' && buffer.text.length === 0) + ) { + dismissBtw(); + return true; + } + } + // Reset completion suppression if the user performs any action other than // history navigation or cursor movement. // We explicitly skip this if we are currently navigating suggestions. @@ -1371,6 +1385,8 @@ export const InputPrompt: React.FC = ({ keyMatchers, isHelpDismissKey, settings, + btwState.isActive, + dismissBtw, ], ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f1959c0173..02b49c987d 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -84,6 +84,7 @@ export interface UIActions { dismissBackgroundTask: (pid: number) => Promise; setActiveBackgroundTaskPid: (pid: number) => void; setIsBackgroundTaskListOpen: (isOpen: boolean) => void; + dismissBtw: () => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; onHintInput: (char: string) => void; onHintBackspace: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 3dd7e96467..ecb459796a 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -218,6 +218,13 @@ export interface UIState { showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; + btwState: { + isActive: boolean; + query: string; + response: string; + isStreaming: boolean; + error: string | null; + }; transientMessage: { text: string; type: TransientMessageType; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index bb1fc3e9b7..de09440895 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -16,6 +16,7 @@ import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { CopyModeWarning } from '../components/CopyModeWarning.js'; import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js'; +import { BtwDisplay } from '../components/BtwDisplay.js'; import { StreamingState } from '../types.js'; import { useInputState } from '../contexts/InputContext.js'; @@ -66,6 +67,15 @@ export const DefaultAppLayout: React.FC = () => { width={uiState.terminalWidth} height={copyModeEnabled ? uiState.stableControlsHeight : undefined} > + {uiState.btwState.isActive && ( + + )}