feat(cli): finalize stable footer UX and fix lint/tests

This commit is contained in:
Keith Guerin
2026-03-01 02:40:01 -08:00
parent e1e863dba2
commit acd24006b6
28 changed files with 484 additions and 498 deletions
@@ -8,4 +8,4 @@ exports[`usePhraseCycler > should reset phrase when transitioning from waiting t
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`] = `"! Shell awaiting input (Tab to focus)"`;
exports[`usePhraseCycler > should show interactive shell waiting message immediately when shouldShowFocusHint is true 1`] = `"! Shell awaiting input (Tab to focus)"`;
@@ -43,6 +43,7 @@ export const useHookDisplayState = () => {
{
name: payload.hookName,
eventName: payload.eventName,
source: payload.source,
index: payload.hookIndex,
total: payload.totalHooks,
},
@@ -16,7 +16,6 @@ import {
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import type { RetryAttemptPayload } from '@google/gemini-cli-core';
import type { LoadingPhrasesMode } from '../../config/settings.js';
describe('useLoadingIndicator', () => {
beforeEach(() => {
@@ -34,7 +33,8 @@ describe('useLoadingIndicator', () => {
initialStreamingState: StreamingState,
initialShouldShowFocusHint: boolean = false,
initialRetryStatus: RetryAttemptPayload | null = null,
loadingPhraseLayout: LoadingPhrasesMode = 'all_inline',
showTips: boolean = true,
showWit: boolean = true,
initialErrorVerbosity: 'low' | 'full' = 'full',
) => {
let hookResult: ReturnType<typeof useLoadingIndicator>;
@@ -42,20 +42,23 @@ describe('useLoadingIndicator', () => {
streamingState,
shouldShowFocusHint,
retryStatus,
mode,
showTips,
showWit,
errorVerbosity,
}: {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
showTips?: boolean;
showWit?: boolean;
errorVerbosity?: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
shouldShowFocusHint: !!shouldShowFocusHint,
retryStatus: retryStatus || null,
loadingPhraseLayout: mode,
showTips,
showWit,
errorVerbosity,
});
return null;
@@ -65,7 +68,8 @@ describe('useLoadingIndicator', () => {
streamingState={initialStreamingState}
shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus}
mode={loadingPhraseLayout}
showTips={showTips}
showWit={showWit}
errorVerbosity={initialErrorVerbosity}
/>,
);
@@ -79,12 +83,14 @@ describe('useLoadingIndicator', () => {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
showTips?: boolean;
showWit?: boolean;
errorVerbosity?: 'low' | 'full';
}) =>
rerender(
<TestComponent
mode={loadingPhraseLayout}
showTips={showTips}
showWit={showWit}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
@@ -93,24 +99,19 @@ describe('useLoadingIndicator', () => {
};
it('should initialize with default values when Idle', () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
expect(result.current.elapsedTime).toBe(0);
expect(result.current.currentLoadingPhrase).toBeUndefined();
});
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
);
// Initially should be witty phrase or tip
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
await act(async () => {
rerender({
streamingState: StreamingState.Responding,
@@ -124,19 +125,17 @@ describe('useLoadingIndicator', () => {
});
it('should reflect values when Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result } = renderLoadingIndicatorHook(StreamingState.Responding);
// Initial phrase on first activation will be a tip, not necessarily from witty phrases
expect(result.current.elapsedTime).toBe(0);
// On first activation, it may show a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
});
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed, now it should be witty since first activation already happened
expect(WITTY_LOADING_PHRASES).toContain(
// Both tip and witty phrase are available in the currentLoadingPhrase because it defaults to tip if present
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
});
@@ -167,8 +166,8 @@ describe('useLoadingIndicator', () => {
expect(result.current.elapsedTime).toBe(60);
});
it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
it('should reset elapsedTime and cycle phrases when transitioning from WaitingForConfirmation to Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
);
@@ -190,7 +189,7 @@ describe('useLoadingIndicator', () => {
rerender({ streamingState: StreamingState.Responding });
});
expect(result.current.elapsedTime).toBe(0); // Should reset
expect(WITTY_LOADING_PHRASES).toContain(
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
@@ -201,7 +200,7 @@ describe('useLoadingIndicator', () => {
});
it('should reset timer and phrase when streamingState changes from Responding to Idle', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
);
@@ -217,79 +216,5 @@ describe('useLoadingIndicator', () => {
expect(result.current.elapsedTime).toBe(0);
expect(result.current.currentLoadingPhrase).toBeUndefined();
// Timer should not advance
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(result.current.elapsedTime).toBe(0);
});
it('should reflect retry status in currentLoadingPhrase when provided', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 3,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
);
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
});
it('should hide low-verbosity retry status for early retry attempts', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 1,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all_inline',
'low',
);
expect(result.current.currentLoadingPhrase).not.toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all_inline',
'low',
);
expect(result.current.currentLoadingPhrase).toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show no phrases when loadingPhraseLayout is "none"', () => {
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
null,
'none',
);
expect(result.current.currentLoadingPhrase).toBeUndefined();
});
});
@@ -12,7 +12,6 @@ import {
getDisplayString,
type RetryAttemptPayload,
} from '@google/gemini-cli-core';
import type { LoadingPhrasesMode } from '../../config/settings.js';
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
@@ -20,7 +19,8 @@ export interface UseLoadingIndicatorProps {
streamingState: StreamingState;
shouldShowFocusHint: boolean;
retryStatus: RetryAttemptPayload | null;
loadingPhraseLayout?: LoadingPhrasesMode;
showTips?: boolean;
showWit?: boolean;
customWittyPhrases?: string[];
errorVerbosity?: 'low' | 'full';
maxLength?: number;
@@ -30,7 +30,8 @@ export const useLoadingIndicator = ({
streamingState,
shouldShowFocusHint,
retryStatus,
loadingPhraseLayout,
showTips = true,
showWit = true,
customWittyPhrases,
errorVerbosity = 'full',
maxLength,
@@ -46,7 +47,8 @@ export const useLoadingIndicator = ({
isPhraseCyclingActive,
isWaiting,
shouldShowFocusHint,
loadingPhraseLayout,
showTips,
showWit,
customWittyPhrases,
maxLength,
);
@@ -14,30 +14,35 @@ import {
} from './usePhraseCycler.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import type { LoadingPhrasesMode } from '../../config/settings.js';
// Test component to consume the hook
const TestComponent = ({
isActive,
isWaiting,
isInteractiveShellWaiting = false,
loadingPhraseLayout = 'all_inline',
shouldShowFocusHint = false,
showTips = true,
showWit = true,
customPhrases,
}: {
isActive: boolean;
isWaiting: boolean;
isInteractiveShellWaiting?: boolean;
loadingPhraseLayout?: LoadingPhrasesMode;
shouldShowFocusHint?: boolean;
showTips?: boolean;
showWit?: boolean;
customPhrases?: string[];
}) => {
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isActive,
isWaiting,
isInteractiveShellWaiting,
loadingPhraseLayout,
shouldShowFocusHint,
showTips,
showWit,
customPhrases,
);
return <Text>{currentTip || currentWittyPhrase}</Text>;
// For tests, we'll combine them to verify existence
return (
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
);
};
describe('usePhraseCycler', () => {
@@ -75,7 +80,7 @@ describe('usePhraseCycler', () => {
unmount();
});
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
it('should show interactive shell waiting message immediately when shouldShowFocusHint is true', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
);
@@ -86,7 +91,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -108,7 +113,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -133,55 +138,56 @@ describe('usePhraseCycler', () => {
unmount();
});
it('should show a tip on first activation, then a witty phrase', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.99); // Subsequent phrases are witty
it('should show both a tip and a witty phrase when both are enabled', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
await waitUntilReady();
// Initial phrase on first activation should be a tip
expect(INFORMATIVE_TIPS).toContain(lastFrame().trim());
// After the first interval, it should be a witty phrase
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
// In the new logic, both are selected independently if enabled.
const frame = lastFrame().trim();
const parts = frame.split(' | ');
expect(parts).toHaveLength(2);
expect(INFORMATIVE_TIPS).toContain(parts[0]);
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
unmount();
});
it('should cycle through phrases when isActive is true and not waiting', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
await waitUntilReady();
// Initial phrase on first activation will be a tip
// After the first interval, it should follow the random pattern (witty phrases due to mock)
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
const frame = lastFrame().trim();
const parts = frame.split(' | ');
expect(parts).toHaveLength(2);
expect(INFORMATIVE_TIPS).toContain(parts[0]);
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should reset to a phrase when isActive becomes true after being false', async () => {
it('should reset to phrases when isActive becomes true after being false', async () => {
const customPhrases = ['Phrase A', 'Phrase B'];
let callCount = 0;
vi.spyOn(Math, 'random').mockImplementation(() => {
// For custom phrases, only 1 Math.random call is made per update.
// 0 -> index 0 ('Phrase A')
// 0.99 -> index 1 ('Phrase B')
const val = callCount % 2 === 0 ? 0 : 0.99;
callCount++;
return val;
@@ -192,34 +198,31 @@ describe('usePhraseCycler', () => {
isActive={false}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
await waitUntilReady();
// Activate -> On first activation will show tip on initial call, then first interval will use first mock value for 'Phrase A'
// Activate
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after initial state -> callCount 0 -> 'Phrase A'
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
// Second interval -> callCount 1 -> returns 0.99 -> 'Phrase B'
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
expect(customPhrases).toContain(lastFrame().trim());
// Deactivate -> resets to undefined (empty string in output)
await act(async () => {
@@ -228,6 +231,8 @@ describe('usePhraseCycler', () => {
isActive={false}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
});
@@ -235,24 +240,6 @@ describe('usePhraseCycler', () => {
// The phrase should be empty after reset
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
// Activate again -> this will show a tip on first activation, then cycle from where mock is
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={false}
customPhrases={customPhrases}
/>,
);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after re-activation -> should contain phrase
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
unmount();
});
@@ -293,7 +280,8 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={config.isActive}
isWaiting={false}
loadingPhraseLayout="wit_inline"
showTips={false}
showWit={true}
customPhrases={config.customPhrases}
/>
);
@@ -304,7 +292,7 @@ describe('usePhraseCycler', () => {
// After first interval, it should use custom phrases
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
@@ -323,78 +311,24 @@ describe('usePhraseCycler', () => {
await waitUntilReady();
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
randomMock.mockReturnValue(0.99);
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
// Test fallback to default phrases.
randomMock.mockRestore();
vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty
await act(async () => {
setStateExternally?.({
isActive: true,
customPhrases: [] as string[],
});
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Wait for first cycle
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should fall back to witty phrases if custom phrases are an empty array', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={false}
showWit={true}
customPhrases={[]}
/>,
);
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Next phrase after tip
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should reset phrase when transitioning from waiting to active', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
);
await waitUntilReady();
// Cycle to a different phrase (should be witty due to mock)
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
// Go to waiting state
await act(async () => {
rerender(<TestComponent isActive={false} isWaiting={true} />);
});
await waitUntilReady();
expect(lastFrame().trim()).toMatchSnapshot();
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
await act(async () => {
rerender(<TestComponent isActive={true} isWaiting={false} />);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Skip the tip and get next phrase
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
+74 -66
View File
@@ -7,7 +7,6 @@
import { useState, useEffect, useRef } from 'react';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import type { LoadingPhrasesMode } from '../../config/settings.js';
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
export const INTERACTIVE_SHELL_WAITING_PHRASE =
@@ -18,26 +17,33 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE =
* @param isActive Whether the phrase cycling should be active.
* @param isWaiting Whether to show a specific waiting phrase.
* @param shouldShowFocusHint Whether to show the shell focus hint.
* @param loadingPhraseLayout Which phrases to show and where.
* @param showTips Whether to show informative tips.
* @param showWit Whether to show witty phrases.
* @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 tip and witty phrase.
* @returns The current loading phrase.
*/
export const usePhraseCycler = (
isActive: boolean,
isWaiting: boolean,
shouldShowFocusHint: boolean,
loadingPhraseLayout: LoadingPhrasesMode = 'all_inline',
showTips: boolean = true,
showWit: boolean = true,
customPhrases?: string[],
maxLength?: number,
) => {
const [currentTip, setCurrentTip] = useState<string | undefined>(undefined);
const [currentWittyPhrase, setCurrentWittyPhrase] = useState<
const [currentTipState, setCurrentTipState] = useState<string | undefined>(
undefined,
);
const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState<
string | undefined
>(undefined);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false);
const lastChangeTimeRef = useRef<number>(0);
const lastSelectedTipRef = useRef<string | undefined>(undefined);
const lastSelectedWittyPhraseRef = useRef<string | undefined>(undefined);
const MIN_TIP_DISPLAY_TIME_MS = 10000;
useEffect(() => {
// Always clear on re-run
@@ -46,86 +52,75 @@ export const usePhraseCycler = (
phraseIntervalRef.current = null;
}
if (shouldShowFocusHint) {
setCurrentTip(INTERACTIVE_SHELL_WAITING_PHRASE);
setCurrentWittyPhrase(undefined);
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
return;
}
if (isWaiting) {
setCurrentTip('Waiting for user confirmation...');
setCurrentWittyPhrase(undefined);
if (!isActive || (!showTips && !showWit)) {
return;
}
if (!isActive || loadingPhraseLayout === 'none') {
setCurrentTip(undefined);
setCurrentWittyPhrase(undefined);
return;
}
const wittyPhrases =
const wittyPhrasesList =
customPhrases && customPhrases.length > 0
? customPhrases
: WITTY_LOADING_PHRASES;
const setRandomPhrase = () => {
let currentMode: 'tips' | 'witty' | 'all' = 'all';
if (loadingPhraseLayout === 'tips') {
currentMode = 'tips';
} else if (
loadingPhraseLayout === 'wit_status' ||
loadingPhraseLayout === 'wit_inline' ||
loadingPhraseLayout === 'wit_ambient'
) {
currentMode = 'witty';
}
// In 'all' modes, we decide once per phrase cycle what to show
const setRandomPhrases = (force: boolean = false) => {
const now = Date.now();
if (
loadingPhraseLayout === 'all_inline' ||
loadingPhraseLayout === 'all_ambient'
!force &&
now - lastChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
(lastSelectedTipRef.current || lastSelectedWittyPhraseRef.current)
) {
if (!hasShownFirstRequestTipRef.current) {
currentMode = 'tips';
hasShownFirstRequestTipRef.current = true;
} else {
currentMode = Math.random() < 1 / 2 ? 'tips' : 'witty';
}
// Sync state if it was cleared by inactivation.
setCurrentTipState(lastSelectedTipRef.current);
setCurrentWittyPhraseState(lastSelectedWittyPhraseRef.current);
return;
}
const phraseList =
currentMode === 'witty' ? wittyPhrases : INFORMATIVE_TIPS;
const adjustedMaxLength = maxLength;
const filteredList =
maxLength !== undefined
? phraseList.filter((p) => p.length <= maxLength)
: phraseList;
if (filteredList.length > 0) {
const randomIndex = Math.floor(Math.random() * filteredList.length);
const selected = filteredList[randomIndex];
if (currentMode === 'witty') {
setCurrentWittyPhrase(selected);
setCurrentTip(undefined);
} else {
setCurrentTip(selected);
setCurrentWittyPhrase(undefined);
if (showTips) {
const filteredTips =
adjustedMaxLength !== undefined
? INFORMATIVE_TIPS.filter((p) => p.length <= adjustedMaxLength)
: INFORMATIVE_TIPS;
if (filteredTips.length > 0) {
const selected =
filteredTips[Math.floor(Math.random() * filteredTips.length)];
setCurrentTipState(selected);
lastSelectedTipRef.current = selected;
}
} else {
// If no phrases fit, try to fallback
setCurrentTip(undefined);
setCurrentWittyPhrase(undefined);
setCurrentTipState(undefined);
lastSelectedTipRef.current = undefined;
}
if (showWit) {
const filteredWitty =
adjustedMaxLength !== undefined
? wittyPhrasesList.filter((p) => p.length <= adjustedMaxLength)
: wittyPhrasesList;
if (filteredWitty.length > 0) {
const selected =
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
setCurrentWittyPhraseState(selected);
lastSelectedWittyPhraseRef.current = selected;
}
} else {
setCurrentWittyPhraseState(undefined);
lastSelectedWittyPhraseRef.current = undefined;
}
lastChangeTimeRef.current = now;
};
// Select an initial random phrase
setRandomPhrase();
// Select initial random phrases or resume previous ones
setRandomPhrases(false);
phraseIntervalRef.current = setInterval(() => {
// Select a new random phrase
setRandomPhrase();
setRandomPhrases(true); // Force change on interval
}, PHRASE_CHANGE_INTERVAL_MS);
return () => {
@@ -138,10 +133,23 @@ export const usePhraseCycler = (
isActive,
isWaiting,
shouldShowFocusHint,
loadingPhraseLayout,
showTips,
showWit,
customPhrases,
maxLength,
]);
let currentTip = undefined;
let currentWittyPhrase = undefined;
if (shouldShowFocusHint) {
currentTip = INTERACTIVE_SHELL_WAITING_PHRASE;
} else if (isWaiting) {
currentTip = 'Waiting for user confirmation...';
} else if (isActive) {
currentTip = currentTipState;
currentWittyPhrase = currentWittyPhraseState;
}
return { currentTip, currentWittyPhrase };
};