diff --git a/_footer-ui.md b/_footer-ui.md index db3eed6b50..40d685adf6 100644 --- a/_footer-ui.md +++ b/_footer-ui.md @@ -730,6 +730,18 @@ better discoverability of features: wit based on the available terminal width, ensuring that only phrases that fit without colliding with the system status are selected. +### 4. Witty Phrase Positioning + +A new setting `ui.wittyPhrasePosition` allows controlling where entertainment +phrases are displayed: + +- **`status`**: Replaces the status text when the model is thinking but hasn't + emitted a specific thought yet. +- **`inline` (Default)**: Appends the witty phrase in gray immediately following + the real system status (e.g., `⠏ Searching... Loading wit.exe`). +- **`ambient`**: Displays witty phrases on the far right, interspersed with + tips. + --- ## 12. Testing Summary & Final Feedback @@ -748,6 +760,7 @@ review against the updated specification. - **Toasts:** Claims 100% width, left-aligned, prominent warning color. Overrides ambient tips. - **Hooks:** Uses `↪` (Before) / `↩` (After) icons. Text is white and italic. +- **Witty Phrases:** Default to `inline` position (gray text after status). - **Responsive:** - Tips/Wit disappear on narrow windows or if they collide with long statuses. - Status text wraps onto multiple lines only when the window is narrow. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d1c7992c41..574017c217 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -633,6 +633,20 @@ const SETTINGS_SCHEMA = { { value: 'new_divider_down', label: 'New Layout (Divider Down)' }, ], }, + wittyPhrasePosition: { + type: 'enum', + label: 'Witty Phrase Position', + category: 'UI', + requiresRestart: false, + default: 'inline', + description: 'Where to show witty phrases while waiting.', + showInDialog: true, + options: [ + { value: 'status', label: 'Status' }, + { value: 'inline', label: 'Inline (after status)' }, + { value: 'ambient', label: 'Ambient (at right)' }, + ], + }, showMemoryUsage: { type: 'boolean', label: 'Show Memory Usage', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 268a0f0295..637a7a8309 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2097,15 +2097,16 @@ Logging in with Google... Restarting Gemini CLI to continue. ? 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 { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } = + useLoadingIndicator({ + streamingState, + shouldShowFocusHint, + retryStatus, + loadingPhrasesMode: settings.merged.ui.loadingPhrases, + customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, + maxLength, + }); const allowPlanMode = config.isPlanEnabled() && @@ -2304,6 +2305,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isFocused, elapsedTime, currentLoadingPhrase, + currentTip, + currentWittyPhrase, historyRemountKey, activeHooks, messageQueue, @@ -2434,6 +2437,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isFocused, elapsedTime, currentLoadingPhrase, + currentTip, + currentWittyPhrase, historyRemountKey, activeHooks, messageQueue, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index acbac114d5..6ebca992d9 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -59,6 +59,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const isAlternateBuffer = useAlternateBuffer(); const { showApprovalModeIndicator } = uiState; const newLayoutSetting = settings.merged.ui.newFooterLayout; + const wittyPosition = settings.merged.ui.wittyPhrasePosition; const isExperimentalLayout = newLayoutSetting !== 'legacy'; const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; @@ -197,15 +198,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const ambientText = isInteractiveShellWaiting ? undefined - : uiState.currentLoadingPhrase; - - // Wit often ends with an ellipsis or similar, tips usually don't. - const isAmbientTip = - ambientText && - !ambientText.includes('…') && - !ambientText.includes('...') && - !ambientText.includes('feeling lucky'); - const ambientPrefix = isAmbientTip ? 'Tip: ' : ''; + : uiState.currentTip || + (wittyPosition === 'ambient' ? uiState.currentWittyPhrase : undefined); let estimatedStatusLength = 0; if ( @@ -225,13 +219,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { estimatedStatusLength = hookLabel.length + hookNames.length + 10; // +10 for spinner and spacing } else if (showLoadingIndicator) { const thoughtText = uiState.thought?.subject || 'Waiting for model...'; - estimatedStatusLength = thoughtText.length + 25; // Spinner(3) + timer(15) + padding + const inlineWittyLength = + wittyPosition === 'inline' && uiState.currentWittyPhrase + ? uiState.currentWittyPhrase.length + 1 + : 0; + estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength; // Spinner(3) + timer(15) + padding + witty } else if (hasPendingActionRequired) { - estimatedStatusLength = 35; // "⏸ Awaiting user approval..." + estimatedStatusLength = 25; // "↑ Awaiting approval" } - const estimatedAmbientLength = - ambientPrefix.length + (ambientText?.length || 0); + const estimatedAmbientLength = ambientText?.length || 0; const willCollideAmbient = estimatedStatusLength + estimatedAmbientLength + 5 > terminalWidth; const willCollideShortcuts = estimatedStatusLength + 45 > terminalWidth; // Assume worst-case shortcut hint is 45 chars @@ -263,7 +260,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return ( - {ambientPrefix} {ambientText} @@ -322,13 +318,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { elapsedTime={uiState.elapsedTime} forceRealStatusOnly={isExperimentalLayout} showCancelAndTimer={!isExperimentalLayout} + wittyPhrase={uiState.currentWittyPhrase} + wittyPosition={wittyPosition} /> ); } if (hasPendingActionRequired) { - return ( - ⏸ Awaiting user approval... - ); + return ↑ Awaiting approval; } return null; }; diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 94bdd23ec3..eda956fd03 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -18,6 +18,8 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; + wittyPhrase?: string; + wittyPosition?: 'status' | 'inline' | 'ambient'; elapsedTime: number; inline?: boolean; rightContent?: React.ReactNode; @@ -29,6 +31,8 @@ interface LoadingIndicatorProps { export const LoadingIndicator: React.FC = ({ currentLoadingPhrase, + wittyPhrase, + wittyPosition = 'inline', elapsedTime, inline = false, rightContent, @@ -57,9 +61,11 @@ export const LoadingIndicator: React.FC = ({ : thought?.subject ? (thoughtLabel ?? thought.subject) : forceRealStatusOnly - ? streamingState === StreamingState.Responding - ? 'Waiting for model...' - : undefined + ? wittyPosition === 'status' && wittyPhrase + ? wittyPhrase + : streamingState === StreamingState.Responding + ? 'Waiting for model...' + : undefined : currentLoadingPhrase; const thinkingIndicator = ''; @@ -69,6 +75,16 @@ export const LoadingIndicator: React.FC = ({ ? `esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)}` : null; + const wittyPhraseNode = + forceRealStatusOnly && + wittyPosition === 'inline' && + wittyPhrase && + primaryText ? ( + + {wittyPhrase} + + ) : null; + if (inline) { return ( @@ -91,6 +107,7 @@ export const LoadingIndicator: React.FC = ({ {primaryText} )} + {wittyPhraseNode} {cancelAndTimerContent && ( <> @@ -129,6 +146,7 @@ export const LoadingIndicator: React.FC = ({ {primaryText} )} + {wittyPhraseNode} {!isNarrow && cancelAndTimerContent && ( <> diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 554cff34f9..ab54dba9f7 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -170,6 +170,8 @@ export interface UIState { cleanUiDetailsVisible: boolean; elapsedTime: number; currentLoadingPhrase: string | undefined; + currentTip: string | undefined; + currentWittyPhrase: string | undefined; historyRemountKey: number; activeHooks: ActiveHook[]; messageQueue: string[]; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index b04df7ea9a..0d9240738f 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -42,7 +42,7 @@ export const useLoadingIndicator = ({ const isPhraseCyclingActive = streamingState === StreamingState.Responding; const isWaiting = streamingState === StreamingState.WaitingForConfirmation; - const currentLoadingPhrase = usePhraseCycler( + const { currentTip, currentWittyPhrase } = usePhraseCycler( isPhraseCyclingActive, isWaiting, shouldShowFocusHint, @@ -89,6 +89,8 @@ export const useLoadingIndicator = ({ streamingState === StreamingState.WaitingForConfirmation ? retainedElapsedTime : elapsedTimeFromTimer, - currentLoadingPhrase: retryPhrase || currentLoadingPhrase, + currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase, + currentTip, + currentWittyPhrase, }; }; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index ca89c623ac..02517f80ec 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -30,14 +30,14 @@ const TestComponent = ({ loadingPhrasesMode?: LoadingPhrasesMode; customPhrases?: string[]; }) => { - const phrase = usePhraseCycler( + const { currentTip, currentWittyPhrase } = usePhraseCycler( isActive, isWaiting, isInteractiveShellWaiting, loadingPhrasesMode, customPhrases, ); - return {phrase}; + return {currentTip || currentWittyPhrase}; }; describe('usePhraseCycler', () => { diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 007844c13a..231b2048e2 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -31,7 +31,8 @@ export const usePhraseCycler = ( customPhrases?: string[], maxLength?: number, ) => { - const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState< + const [currentTip, setCurrentTip] = useState(undefined); + const [currentWittyPhrase, setCurrentWittyPhrase] = useState< string | undefined >(undefined); @@ -46,17 +47,20 @@ export const usePhraseCycler = ( } if (shouldShowFocusHint) { - setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE); + setCurrentTip(INTERACTIVE_SHELL_WAITING_PHRASE); + setCurrentWittyPhrase(undefined); return; } if (isWaiting) { - setCurrentLoadingPhrase('Waiting for user confirmation...'); + setCurrentTip('Waiting for user confirmation...'); + setCurrentWittyPhrase(undefined); return; } if (!isActive || loadingPhrasesMode === 'off') { - setCurrentLoadingPhrase(undefined); + setCurrentTip(undefined); + setCurrentWittyPhrase(undefined); return; } @@ -66,7 +70,6 @@ export const usePhraseCycler = ( : WITTY_LOADING_PHRASES; const setRandomPhrase = () => { - let phraseList: readonly string[]; let currentMode = loadingPhrasesMode; // In 'all' mode, we decide once per phrase cycle what to show @@ -79,23 +82,12 @@ export const usePhraseCycler = ( } } - switch (currentMode) { - case 'tips': - phraseList = INFORMATIVE_TIPS; - break; - case 'witty': - phraseList = wittyPhrases; - break; - default: - phraseList = INFORMATIVE_TIPS; - break; - } + const phraseList = + currentMode === 'witty' ? wittyPhrases : INFORMATIVE_TIPS; // 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 adjustedMaxLength = maxLength; const filteredList = adjustedMaxLength !== undefined @@ -104,10 +96,18 @@ export const usePhraseCycler = ( if (filteredList.length > 0) { const randomIndex = Math.floor(Math.random() * filteredList.length); - setCurrentLoadingPhrase(filteredList[randomIndex]); + const selected = filteredList[randomIndex]; + if (currentMode === 'witty') { + setCurrentWittyPhrase(selected); + setCurrentTip(undefined); + } else { + setCurrentTip(selected); + setCurrentWittyPhrase(undefined); + } } else { // If no phrases fit, try to fallback to a very short list or nothing - setCurrentLoadingPhrase(undefined); + setCurrentTip(undefined); + setCurrentWittyPhrase(undefined); } }; @@ -134,5 +134,5 @@ export const usePhraseCycler = ( maxLength, ]); - return currentLoadingPhrase; + return { currentTip, currentWittyPhrase }; };