From d70feffcdcd07fdacbf5fd2cd5decfa9f09f9e3a Mon Sep 17 00:00:00 2001 From: ameliesther Date: Wed, 23 Sep 2026 14:10:27 -0600 Subject: [PATCH 1/2] fix(cli): display retry progress indicator during connection recovery (#28340) - Decouple actionable statusPhrase from cosmetic loading phrases in useLoadingIndicator - Expose statusPhrase in UIState and forward through StatusRow/StatusNode to LoadingIndicator - Prioritize statusPhrase over thought.subject in LoadingIndicator so connection retries are always visible - Add comprehensive unit tests in StatusRow, LoadingIndicator, and useLoadingIndicator --- packages/cli/src/ui/AppContainer.tsx | 29 ++- .../ui/components/LoadingIndicator.test.tsx | 37 +++ .../src/ui/components/LoadingIndicator.tsx | 12 +- .../cli/src/ui/components/StatusRow.test.tsx | 220 ++++++++++++++++++ packages/cli/src/ui/components/StatusRow.tsx | 7 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../src/ui/hooks/useLoadingIndicator.test.tsx | 4 + .../cli/src/ui/hooks/useLoadingIndicator.ts | 1 + 8 files changed, 296 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 801e230c8aa..aaa26fc221c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2260,17 +2260,22 @@ Logging in with Google... Restarting Gemini CLI to continue. const maxLength = terminalWidth - estimatedStatusLength - 5; - const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } = - useLoadingIndicator({ - streamingState, - shouldShowFocusHint, - retryStatus, - showTips: showStatusTips, - showWit: showStatusWit, - customWittyPhrases: settings.merged.ui.customWittyPhrases, - errorVerbosity: settings.merged.ui.errorVerbosity, - maxLength, - }); + const { + elapsedTime, + currentLoadingPhrase, + statusPhrase, + currentTip, + currentWittyPhrase, + } = useLoadingIndicator({ + streamingState, + shouldShowFocusHint, + retryStatus, + showTips: showStatusTips, + showWit: showStatusWit, + customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, + maxLength, + }); const allowPlanMode = config.isPlanEnabled() && @@ -2493,6 +2498,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isFocused, elapsedTime, currentLoadingPhrase, + statusPhrase, currentTip, currentWittyPhrase, historyRemountKey, @@ -2606,6 +2612,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isFocused, elapsedTime, currentLoadingPhrase, + statusPhrase, currentTip, currentWittyPhrase, historyRemountKey, diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 003a0dc0703..f103e13b985 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -316,6 +316,43 @@ describe('', () => { unmount(); }); + it('should prioritize statusPhrase over thought.subject', async () => { + const props = { + thought: { + subject: 'Thinking...', + description: 'A description', + }, + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)', + elapsedTime: 5, + }; + const { lastFrame, unmount, waitUntilReady } = await renderWithContext( + , + StreamingState.Responding, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)'); + expect(output).not.toContain('Thinking...'); + unmount(); + }); + + it('should prioritize statusPhrase over currentLoadingPhrase', async () => { + const props = { + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 2/5)', + currentLoadingPhrase: 'Tip: Use /clear to reset history', + elapsedTime: 5, + }; + const { lastFrame, unmount, waitUntilReady } = await renderWithContext( + , + StreamingState.Responding, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 2/5)'); + expect(output).not.toContain('Tip: Use /clear to reset history'); + unmount(); + }); + it('should not display thought indicator for non-thought loading phrases', async () => { const { lastFrame, unmount, waitUntilReady } = await renderWithContext( = ({ currentLoadingPhrase, + statusPhrase, wittyPhrase, showWit = false, elapsedTime, @@ -54,22 +56,24 @@ export const LoadingIndicator: React.FC = ({ if ( streamingState === StreamingState.Idle && !currentLoadingPhrase && + !statusPhrase && !thought ) { return null; } - // Prioritize the interactive shell waiting phrase over the thought subject - // because it conveys an actionable state for the user (waiting for input). + // Prioritize active operational status (e.g. retries) or interactive shell waiting + // over thought subject, while keeping cosmetic tips/witty phrases subordinate to thoughts. const primaryText = - currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE + statusPhrase ?? + (currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE ? currentLoadingPhrase : thought?.subject ? (thoughtLabel ?? thought.subject) : currentLoadingPhrase || (streamingState === StreamingState.Responding ? 'Thinking...' - : undefined); + : undefined)); const cancelAndTimerContent = showCancelAndTimer && streamingState === StreamingState.Responding diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index 5f14254f4b1..f3a5d031d52 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -140,4 +140,224 @@ describe('', () => { await waitUntilReady(); expect(lastFrame()).toContain('Tip: Test Tip'); }); + + it('renders retry status phrase from uiState when retrying connection', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)', + elapsedTime: 5, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)'); + expect(output).not.toContain('Thinking...'); + }); + + it('prioritizes retry status over thought subject when thought is present', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)', + thought: { subject: 'Thinking...' } as unknown as ThoughtSummary, + elapsedTime: 5, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)'); + expect(output).not.toContain('Thinking...'); + }); + + it('renders statusPhrase passed directly via props', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState: defaultUiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-pro (Attempt 2/3)'); + }); + + it('renders generic retry phrase in low error verbosity mode when attempt threshold is met', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + statusPhrase: "This is taking a bit longer, we're still on it.", + elapsedTime: 8, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain("This is taking a bit longer, we're still on it."); + }); + + it('renders active hook status and does not crash when active hooks are present', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + activeHooks: [ + { + name: 'linter', + eventName: 'before-command', + source: 'user', + }, + ], + elapsedTime: 2, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Executing Hook: linter'); + }); + + it('renders retry status correctly in narrow terminal mode', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)', + elapsedTime: 3, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 60, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index f162481ce58..bec11983e14 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -49,6 +49,7 @@ interface StatusRowProps { hideContextSummary: boolean; hideUiDetailsForSuggestions: boolean; hasPendingActionRequired: boolean; + statusPhrase?: string; } /** @@ -63,6 +64,7 @@ export const StatusNode: React.FC<{ activeHooks: ActiveHook[]; showLoadingIndicator: boolean; errorVerbosity: 'low' | 'full' | undefined; + statusPhrase?: string | undefined; onResize?: (width: number) => void; }> = ({ showTips, @@ -73,6 +75,7 @@ export const StatusNode: React.FC<{ activeHooks, showLoadingIndicator, errorVerbosity, + statusPhrase, onResize, }) => { const observerRef = useRef(null); @@ -145,6 +148,7 @@ export const StatusNode: React.FC<{ errorVerbosity={errorVerbosity} thought={currentThought} currentLoadingPhrase={currentLoadingPhrase} + statusPhrase={statusPhrase} elapsedTime={elapsedTime} forceRealStatusOnly={false} wittyPhrase={currentWittyPhrase} @@ -162,8 +166,10 @@ export const StatusRow: React.FC = ({ hideContextSummary, hideUiDetailsForSuggestions, hasPendingActionRequired, + statusPhrase: propStatusPhrase, }) => { const uiState = useUIState(); + const statusPhrase = propStatusPhrase ?? uiState.statusPhrase; const inputState = useInputState(); const settings = useSettings(); const { @@ -269,6 +275,7 @@ export const StatusRow: React.FC = ({ errorVerbosity={ settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined } + statusPhrase={statusPhrase} onResize={onStatusResize} /> ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 77412a0f48e..cf8165f2c03 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -154,6 +154,7 @@ export interface UIState { cleanUiDetailsVisible: boolean; elapsedTime: number; currentLoadingPhrase: string | undefined; + statusPhrase?: string | undefined; currentTip: string | undefined; currentWittyPhrase: string | undefined; historyRemountKey: number; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index c723cf7ef5c..2904db282a9 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -239,6 +239,9 @@ describe('useLoadingIndicator', () => { expect(result.current.currentLoadingPhrase).toContain('Trying to reach'); expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3'); + expect(result.current.statusPhrase).toBe( + 'Trying to reach gemini-pro (Attempt 3/3)', + ); }); it('should not show retry status phrase when idle', async () => { @@ -255,6 +258,7 @@ describe('useLoadingIndicator', () => { ); expect(result.current.currentLoadingPhrase).toBeUndefined(); + expect(result.current.statusPhrase).toBeUndefined(); }); it('should hide low-verbosity retry status for early retry attempts', async () => { diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 0e2dc9c2b1e..379880fabd0 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -94,6 +94,7 @@ export const useLoadingIndicator = ({ ? retainedElapsedTime : elapsedTimeFromTimer, currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase, + statusPhrase: retryPhrase ?? undefined, currentTip, currentWittyPhrase, }; From a80640a183153662cdc9b35f9690b8e91c527dd8 Mon Sep 17 00:00:00 2001 From: ameliesther Date: Wed, 23 Sep 2026 14:38:18 -0600 Subject: [PATCH 2/2] fix(cli): preserve statusPhrase visibility when showLoadingIndicator is false --- .../cli/src/ui/components/StatusRow.test.tsx | 36 +++++++++++++++++++ packages/cli/src/ui/components/StatusRow.tsx | 9 +++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index f3a5d031d52..32c3f512f48 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -360,4 +360,40 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)'); }); + + it('renders statusPhrase even when showLoadingIndicator is false', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: false, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 2/5)', + elapsedTime: 4, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 2/5)'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index bec11983e14..8676a76fc47 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -108,7 +108,9 @@ export const StatusNode: React.FC<{ [onResize], ); - if (activeHooks.length === 0 && !showLoadingIndicator) return null; + if (activeHooks.length === 0 && !showLoadingIndicator && !statusPhrase) { + return null; + } let currentLoadingPhrase: string | undefined = undefined; let currentThought: ThoughtSummary | null = null; @@ -251,7 +253,10 @@ export const StatusRow: React.FC = ({ ); const showRow1Minimal = - showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine; + showLoadingIndicator || + uiState.activeHooks.length > 0 || + showTipLine || + Boolean(statusPhrase); const showRow2Minimal = (Boolean(modeContentObj) && !hideUiDetailsForSuggestions) || showMinimalContext;