From 384be6063580d2f7caf143b11ed44cac0af0815c Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Sat, 28 Feb 2026 13:37:10 -0800 Subject: [PATCH] feat(cli): implement width-aware phrase selection for footer tips - Update usePhraseCycler to filter phrase list based on available width - Move status length estimation logic to AppContainer - Ensure tips are only selected if they fit the remaining terminal width - Update snapshots for usePhraseCycler --- packages/cli/src/ui/AppContainer.tsx | 53 +++++++++++++++---- .../usePhraseCycler.test.tsx.snap | 4 +- .../cli/src/ui/hooks/useLoadingIndicator.ts | 3 ++ packages/cli/src/ui/hooks/usePhraseCycler.ts | 46 +++++++++++----- 4 files changed, 82 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 869e8ecff6..268a0f0295 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1684,15 +1684,6 @@ Logging in with Google... Restarting Gemini CLI to continue. [handleSlashCommand, settings], ); - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({ - streamingState, - shouldShowFocusHint, - retryStatus, - loadingPhrasesMode: settings.merged.ui.loadingPhrases, - customWittyPhrases: settings.merged.ui.customWittyPhrases, - errorVerbosity: settings.merged.ui.errorVerbosity, - }); - const handleGlobalKeypress = useCallback( (key: Key): boolean => { // Debug log keystrokes if enabled @@ -2072,6 +2063,50 @@ Logging in with Google... Restarting Gemini CLI to continue. !!emptyWalletRequest || !!customDialog; + const newLayoutSetting = settings.merged.ui.newFooterLayout; + const isExperimentalLayout = newLayoutSetting !== 'legacy'; + const showLoadingIndicator = + (!embeddedShellFocused || isBackgroundShellVisible) && + streamingState === StreamingState.Responding && + !hasPendingActionRequired; + + let estimatedStatusLength = 0; + if ( + isExperimentalLayout && + activeHooks.length > 0 && + settings.merged.hooksConfig.notifications + ) { + const hookLabel = + activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; + const hookNames = activeHooks + .map( + (h) => + h.name + + (h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''), + ) + .join(', '); + estimatedStatusLength = hookLabel.length + hookNames.length + 10; + } else if (showLoadingIndicator) { + const thoughtText = thought?.subject || 'Waiting for model...'; + estimatedStatusLength = thoughtText.length + 25; + } else if (hasPendingActionRequired) { + estimatedStatusLength = 35; + } + + const maxLength = isExperimentalLayout + ? terminalWidth - estimatedStatusLength - 5 + : undefined; + + const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({ + streamingState, + shouldShowFocusHint, + retryStatus, + loadingPhrasesMode: settings.merged.ui.loadingPhrases, + customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, + maxLength, + }); + const allowPlanMode = config.isPlanEnabled() && streamingState === StreamingState.Idle && diff --git a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap index 77d028caa7..f42967127f 100644 --- a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap +++ b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap @@ -2,10 +2,10 @@ exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`; -exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`; +exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"! Shell awaiting input (Tab to focus)"`; exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`; exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`; -exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`; +exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"! Shell awaiting input (Tab to focus)"`; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index ee46589d12..b04df7ea9a 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -23,6 +23,7 @@ export interface UseLoadingIndicatorProps { loadingPhrasesMode?: LoadingPhrasesMode; customWittyPhrases?: string[]; errorVerbosity?: 'low' | 'full'; + maxLength?: number; } export const useLoadingIndicator = ({ @@ -32,6 +33,7 @@ export const useLoadingIndicator = ({ loadingPhrasesMode, customWittyPhrases, errorVerbosity = 'full', + maxLength, }: UseLoadingIndicatorProps) => { const [timerResetKey, setTimerResetKey] = useState(0); const isTimerActive = streamingState === StreamingState.Responding; @@ -46,6 +48,7 @@ export const useLoadingIndicator = ({ shouldShowFocusHint, loadingPhrasesMode, customWittyPhrases, + maxLength, ); const [retainedElapsedTime, setRetainedElapsedTime] = useState(0); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index dc46bb6948..007844c13a 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -20,6 +20,7 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE = * @param shouldShowFocusHint Whether to show the shell focus hint. * @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off. * @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases. + * @param maxLength Optional maximum length for the selected phrase. * @returns The current loading phrase. */ export const usePhraseCycler = ( @@ -28,6 +29,7 @@ export const usePhraseCycler = ( shouldShowFocusHint: boolean, loadingPhrasesMode: LoadingPhrasesMode = 'tips', customPhrases?: string[], + maxLength?: number, ) => { const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState< string | undefined @@ -65,31 +67,48 @@ export const usePhraseCycler = ( const setRandomPhrase = () => { let phraseList: readonly string[]; + let currentMode = loadingPhrasesMode; - switch (loadingPhrasesMode) { + // In 'all' mode, we decide once per phrase cycle what to show + if (loadingPhrasesMode === 'all') { + if (!hasShownFirstRequestTipRef.current) { + currentMode = 'tips'; + hasShownFirstRequestTipRef.current = true; + } else { + currentMode = Math.random() < 1 / 2 ? 'tips' : 'witty'; + } + } + + switch (currentMode) { case 'tips': phraseList = INFORMATIVE_TIPS; break; case 'witty': phraseList = wittyPhrases; break; - case 'all': - // Show a tip on the first request after startup, then continue with 1/2 chance - if (!hasShownFirstRequestTipRef.current) { - phraseList = INFORMATIVE_TIPS; - hasShownFirstRequestTipRef.current = true; - } else { - const showTip = Math.random() < 1 / 2; - phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases; - } - break; default: phraseList = INFORMATIVE_TIPS; break; } - const randomIndex = Math.floor(Math.random() * phraseList.length); - setCurrentLoadingPhrase(phraseList[randomIndex]); + // If we have a maxLength, we need to account for potential prefixes. + // Tips are prefixed with "Tip: " in the Composer UI. + const prefixLength = currentMode === 'tips' ? 5 : 0; + const adjustedMaxLength = + maxLength !== undefined ? maxLength - prefixLength : undefined; + + const filteredList = + adjustedMaxLength !== undefined + ? phraseList.filter((p) => p.length <= adjustedMaxLength) + : phraseList; + + if (filteredList.length > 0) { + const randomIndex = Math.floor(Math.random() * filteredList.length); + setCurrentLoadingPhrase(filteredList[randomIndex]); + } else { + // If no phrases fit, try to fallback to a very short list or nothing + setCurrentLoadingPhrase(undefined); + } }; // Select an initial random phrase @@ -112,6 +131,7 @@ export const usePhraseCycler = ( shouldShowFocusHint, loadingPhrasesMode, customPhrases, + maxLength, ]); return currentLoadingPhrase;