feat(ui): implement refreshed UX for Composer layout (#21212)

Co-authored-by: Keith Guerin <keithguerin@gmail.com>
This commit is contained in:
Jarrod Whelan
2026-03-23 19:30:48 -07:00
committed by GitHub
parent 1560131f94
commit 271908dc94
50 changed files with 1578 additions and 1362 deletions

View File

@@ -1,11 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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 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"`;

View File

@@ -43,6 +43,7 @@ export const useHookDisplayState = () => {
{
name: payload.hookName,
eventName: payload.eventName,
source: payload.source,
index: payload.hookIndex,
total: payload.totalHooks,
},

View File

@@ -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,
loadingPhrasesMode: LoadingPhrasesMode = 'all',
initialShowTips: boolean = true,
initialShowWit: boolean = true,
initialErrorVerbosity: 'low' | 'full' = 'full',
) => {
let hookResult: ReturnType<typeof useLoadingIndicator>;
@@ -42,30 +42,35 @@ describe('useLoadingIndicator', () => {
streamingState,
shouldShowFocusHint,
retryStatus,
mode,
showTips,
showWit,
errorVerbosity,
}: {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity: 'low' | 'full';
showTips?: boolean;
showWit?: boolean;
errorVerbosity?: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
shouldShowFocusHint: !!shouldShowFocusHint,
retryStatus: retryStatus || null,
loadingPhrasesMode: mode,
showTips,
showWit,
errorVerbosity,
});
return null;
}
const { rerender } = await render(
const { rerender, waitUntilReady } = await render(
<TestComponent
streamingState={initialStreamingState}
shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus}
mode={loadingPhrasesMode}
showTips={initialShowTips}
showWit={initialShowWit}
errorVerbosity={initialErrorVerbosity}
/>,
);
@@ -75,20 +80,25 @@ describe('useLoadingIndicator', () => {
return hookResult;
},
},
rerender: (newProps: {
rerender: async (newProps: {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
showTips?: boolean;
showWit?: boolean;
errorVerbosity?: 'low' | 'full';
}) =>
}) => {
rerender(
<TestComponent
mode={loadingPhrasesMode}
showTips={initialShowTips}
showWit={initialShowWit}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
),
);
await waitUntilReady();
},
waitUntilReady,
};
};
@@ -106,13 +116,8 @@ describe('useLoadingIndicator', () => {
false,
);
// Initially should be witty phrase or tip
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
await act(async () => {
rerender({
await rerender({
streamingState: StreamingState.Responding,
shouldShowFocusHint: true,
});
@@ -129,16 +134,14 @@ describe('useLoadingIndicator', () => {
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,
);
});
@@ -153,8 +156,8 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(60);
act(() => {
rerender({ streamingState: StreamingState.WaitingForConfirmation });
await act(async () => {
await rerender({ streamingState: StreamingState.WaitingForConfirmation });
});
expect(result.current.currentLoadingPhrase).toBe(
@@ -169,7 +172,7 @@ describe('useLoadingIndicator', () => {
expect(result.current.elapsedTime).toBe(60);
});
it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => {
it('should reset elapsedTime and cycle phrases when transitioning from WaitingForConfirmation to Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { result, rerender } = await renderLoadingIndicatorHook(
StreamingState.Responding,
@@ -180,19 +183,19 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(5);
act(() => {
rerender({ streamingState: StreamingState.WaitingForConfirmation });
await act(async () => {
await rerender({ streamingState: StreamingState.WaitingForConfirmation });
});
expect(result.current.elapsedTime).toBe(5);
expect(result.current.currentLoadingPhrase).toBe(
'Waiting for user confirmation...',
);
act(() => {
rerender({ streamingState: StreamingState.Responding });
await act(async () => {
await 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,
);
@@ -213,18 +216,12 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(10);
act(() => {
rerender({ streamingState: StreamingState.Idle });
await act(async () => {
await rerender({ streamingState: StreamingState.Idle });
});
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', async () => {
@@ -255,7 +252,8 @@ describe('useLoadingIndicator', () => {
StreamingState.Responding,
false,
retryStatus,
'all',
true,
true,
'low',
);
@@ -275,7 +273,8 @@ describe('useLoadingIndicator', () => {
StreamingState.Responding,
false,
retryStatus,
'all',
true,
true,
'low',
);
@@ -284,12 +283,13 @@ describe('useLoadingIndicator', () => {
);
});
it('should show no phrases when loadingPhrasesMode is "off"', async () => {
it('should show no phrases when showTips and showWit are false', async () => {
const { result } = await renderLoadingIndicatorHook(
StreamingState.Responding,
false,
null,
'off',
false,
false,
);
expect(result.current.currentLoadingPhrase).toBeUndefined();

View File

@@ -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,18 +19,22 @@ export interface UseLoadingIndicatorProps {
streamingState: StreamingState;
shouldShowFocusHint: boolean;
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
showTips?: boolean;
showWit?: boolean;
customWittyPhrases?: string[];
errorVerbosity: 'low' | 'full';
errorVerbosity?: 'low' | 'full';
maxLength?: number;
}
export const useLoadingIndicator = ({
streamingState,
shouldShowFocusHint,
retryStatus,
loadingPhrasesMode,
showTips = true,
showWit = false,
customWittyPhrases,
errorVerbosity,
errorVerbosity = 'full',
maxLength,
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
@@ -40,12 +43,15 @@ export const useLoadingIndicator = ({
const isPhraseCyclingActive = streamingState === StreamingState.Responding;
const isWaiting = streamingState === StreamingState.WaitingForConfirmation;
const currentLoadingPhrase = usePhraseCycler(
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isPhraseCyclingActive,
isWaiting,
shouldShowFocusHint,
loadingPhrasesMode,
showTips,
showWit,
customWittyPhrases,
maxLength,
);
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
@@ -86,6 +92,8 @@ export const useLoadingIndicator = ({
streamingState === StreamingState.WaitingForConfirmation
? retainedElapsedTime
: elapsedTimeFromTimer,
currentLoadingPhrase: retryPhrase || currentLoadingPhrase,
currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase,
currentTip,
currentWittyPhrase,
};
};

View File

@@ -11,33 +11,39 @@ import { Text } from 'ink';
import {
usePhraseCycler,
PHRASE_CHANGE_INTERVAL_MS,
INTERACTIVE_SHELL_WAITING_PHRASE,
} 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,
loadingPhrasesMode = 'all',
shouldShowFocusHint = false,
showTips = true,
showWit = true,
customPhrases,
}: {
isActive: boolean;
isWaiting: boolean;
isInteractiveShellWaiting?: boolean;
loadingPhrasesMode?: LoadingPhrasesMode;
shouldShowFocusHint?: boolean;
showTips?: boolean;
showWit?: boolean;
customPhrases?: string[];
}) => {
const phrase = usePhraseCycler(
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isActive,
isWaiting,
isInteractiveShellWaiting,
loadingPhrasesMode,
shouldShowFocusHint,
showTips,
showWit,
customPhrases,
);
return <Text>{phrase}</Text>;
// For tests, we'll combine them to verify existence
return (
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
);
};
describe('usePhraseCycler', () => {
@@ -52,9 +58,10 @@ describe('usePhraseCycler', () => {
it('should initialize with an empty string when not active and not waiting', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<TestComponent isActive={false} isWaiting={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
unmount();
});
@@ -63,33 +70,35 @@ describe('usePhraseCycler', () => {
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
<TestComponent isActive={true} isWaiting={false} />,
);
await waitUntilReady();
await act(async () => {
rerender(<TestComponent isActive={true} isWaiting={true} />);
});
await waitUntilReady();
expect(lastFrame().trim()).toMatchSnapshot();
expect(lastFrame().trim()).toBe('Waiting for user confirmation...');
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 } = await render(
<TestComponent isActive={true} isWaiting={false} />,
);
await waitUntilReady();
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
await waitUntilReady();
expect(lastFrame().trim()).toMatchSnapshot();
expect(lastFrame().trim()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE);
unmount();
});
@@ -97,19 +106,20 @@ describe('usePhraseCycler', () => {
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
<TestComponent isActive={true} isWaiting={true} />,
);
expect(lastFrame().trim()).toMatchSnapshot();
await waitUntilReady();
expect(lastFrame().trim()).toBe('Waiting for user confirmation...');
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
await waitUntilReady();
expect(lastFrame().trim()).toMatchSnapshot();
expect(lastFrame().trim()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE);
unmount();
});
@@ -117,6 +127,7 @@ describe('usePhraseCycler', () => {
const { lastFrame, waitUntilReady, unmount } = await render(
<TestComponent isActive={false} isWaiting={false} />,
);
await waitUntilReady();
const initialPhrase = lastFrame({ allowEmpty: true }).trim();
await act(async () => {
@@ -128,53 +139,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 } = await render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
// 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 } = await render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
// Initial phrase on first activation will be a tip
await waitUntilReady();
// 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;
@@ -185,33 +199,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 () => {
@@ -220,6 +232,8 @@ describe('usePhraseCycler', () => {
isActive={false}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
});
@@ -227,35 +241,18 @@ 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();
});
it('should clear phrase interval on unmount when active', async () => {
const { unmount } = await render(
const { unmount, waitUntilReady } = await render(
<TestComponent isActive={true} isWaiting={false} />,
);
await waitUntilReady();
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalledOnce();
expect(clearIntervalSpy).toHaveBeenCalled();
});
it('should use custom phrases when provided', async () => {
@@ -284,7 +281,8 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={config.isActive}
isWaiting={false}
loadingPhrasesMode="witty"
showTips={false}
showWit={true}
customPhrases={config.customPhrases}
/>
);
@@ -293,10 +291,11 @@ describe('usePhraseCycler', () => {
const { lastFrame, unmount, waitUntilReady } = await render(
<StatefulWrapper />,
);
await waitUntilReady();
// 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();
@@ -315,73 +314,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
const { lastFrame, unmount, waitUntilReady } = await render(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = await render(
<TestComponent
isActive={true}
isWaiting={false}
showTips={false}
showWit={true}
customPhrases={[]}
/>,
);
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, unmount, waitUntilReady } = await render(
<TestComponent isActive={true} isWaiting={false} />,
);
// 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());

View File

@@ -7,112 +7,177 @@
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 PHRASE_CHANGE_INTERVAL_MS = 10000;
export const WITTY_PHRASE_CHANGE_INTERVAL_MS = 5000;
export const INTERACTIVE_SHELL_WAITING_PHRASE =
'Interactive shell awaiting input... press tab to focus shell';
'! Shell awaiting input (Tab to focus)';
/**
* Custom hook to manage cycling through loading phrases.
* @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 loadingPhrasesMode Which phrases to show: tips, witty, all, or off.
* @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 loading phrase.
*/
export const usePhraseCycler = (
isActive: boolean,
isWaiting: boolean,
shouldShowFocusHint: boolean,
loadingPhrasesMode: LoadingPhrasesMode = 'tips',
showTips: boolean = true,
showWit: boolean = true,
customPhrases?: string[],
maxLength?: number,
) => {
const [currentLoadingPhrase, setCurrentLoadingPhrase] = 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 tipIntervalRef = useRef<NodeJS.Timeout | null>(null);
const wittyIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastTipChangeTimeRef = useRef<number>(0);
const lastWittyChangeTimeRef = useRef<number>(0);
const lastSelectedTipRef = useRef<string | undefined>(undefined);
const lastSelectedWittyPhraseRef = useRef<string | undefined>(undefined);
const MIN_TIP_DISPLAY_TIME_MS = 10000;
const MIN_WIT_DISPLAY_TIME_MS = 5000;
useEffect(() => {
// Always clear on re-run
if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null;
}
const clearTimers = () => {
if (tipIntervalRef.current) {
clearInterval(tipIntervalRef.current);
tipIntervalRef.current = null;
}
if (wittyIntervalRef.current) {
clearInterval(wittyIntervalRef.current);
wittyIntervalRef.current = null;
}
};
if (shouldShowFocusHint) {
setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE);
clearTimers();
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
return;
}
if (isWaiting) {
setCurrentLoadingPhrase('Waiting for user confirmation...');
if (!isActive || (!showTips && !showWit)) {
return;
}
if (!isActive || loadingPhrasesMode === 'off') {
setCurrentLoadingPhrase(undefined);
return;
}
const wittyPhrases =
const wittyPhrasesList =
customPhrases && customPhrases.length > 0
? customPhrases
: WITTY_LOADING_PHRASES;
const setRandomPhrase = () => {
let phraseList: readonly string[];
switch (loadingPhrasesMode) {
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/6 chance
if (!hasShownFirstRequestTipRef.current) {
phraseList = INFORMATIVE_TIPS;
hasShownFirstRequestTipRef.current = true;
} else {
const showTip = Math.random() < 1 / 6;
phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases;
}
break;
default:
phraseList = INFORMATIVE_TIPS;
break;
const setRandomTip = (force: boolean = false) => {
if (!showTips) {
setCurrentTipState(undefined);
lastSelectedTipRef.current = undefined;
return;
}
const randomIndex = Math.floor(Math.random() * phraseList.length);
setCurrentLoadingPhrase(phraseList[randomIndex]);
};
const now = Date.now();
if (
!force &&
now - lastTipChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
lastSelectedTipRef.current
) {
setCurrentTipState(lastSelectedTipRef.current);
return;
}
// Select an initial random phrase
setRandomPhrase();
const filteredTips =
maxLength !== undefined
? INFORMATIVE_TIPS.filter((p) => p.length <= maxLength)
: INFORMATIVE_TIPS;
phraseIntervalRef.current = setInterval(() => {
// Select a new random phrase
setRandomPhrase();
}, PHRASE_CHANGE_INTERVAL_MS);
return () => {
if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null;
if (filteredTips.length > 0) {
const selected =
filteredTips[Math.floor(Math.random() * filteredTips.length)];
setCurrentTipState(selected);
lastSelectedTipRef.current = selected;
lastTipChangeTimeRef.current = now;
}
};
const setRandomWitty = (force: boolean = false) => {
if (!showWit) {
setCurrentWittyPhraseState(undefined);
lastSelectedWittyPhraseRef.current = undefined;
return;
}
const now = Date.now();
if (
!force &&
now - lastWittyChangeTimeRef.current < MIN_WIT_DISPLAY_TIME_MS &&
lastSelectedWittyPhraseRef.current
) {
setCurrentWittyPhraseState(lastSelectedWittyPhraseRef.current);
return;
}
const filteredWitty =
maxLength !== undefined
? wittyPhrasesList.filter((p) => p.length <= maxLength)
: wittyPhrasesList;
if (filteredWitty.length > 0) {
const selected =
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
setCurrentWittyPhraseState(selected);
lastSelectedWittyPhraseRef.current = selected;
lastWittyChangeTimeRef.current = now;
}
};
// Select initial random phrases or resume previous ones
setRandomTip(false);
setRandomWitty(false);
if (showTips) {
tipIntervalRef.current = setInterval(() => {
setRandomTip(true);
}, PHRASE_CHANGE_INTERVAL_MS);
}
if (showWit) {
wittyIntervalRef.current = setInterval(() => {
setRandomWitty(true);
}, WITTY_PHRASE_CHANGE_INTERVAL_MS);
}
return clearTimers;
}, [
isActive,
isWaiting,
shouldShowFocusHint,
loadingPhrasesMode,
showTips,
showWit,
customPhrases,
maxLength,
]);
return currentLoadingPhrase;
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 };
};