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
This commit is contained in:
Keith Guerin
2026-02-28 13:37:10 -08:00
parent e63207dfec
commit 384be60635
4 changed files with 82 additions and 24 deletions

View File

@@ -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 &&

View File

@@ -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)"`;

View File

@@ -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);

View File

@@ -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;