feat(cli): implement customizable witty phrase positioning

- Add ui.wittyPhrasePosition setting (status, inline, ambient)
- Refactor usePhraseCycler to return tips and wit separately
- Implement 'inline' position: append witty phrases in gray after status
- Update status length estimation to account for inline wit
- Replace pause icon with up arrow (↑) for awaiting approval
- Remove 'Tip:' prefix from loading phrases
- Update unit tests and research report
This commit is contained in:
Keith Guerin
2026-02-28 23:25:06 -08:00
parent 5e87ba8be3
commit 3bd36ce4f0
9 changed files with 105 additions and 55 deletions
+14
View File
@@ -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',
+14 -9
View File
@@ -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,
+13 -17
View File
@@ -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 (
<Box flexDirection="row" justifyContent="flex-end" marginLeft={1}>
<Text color={theme.text.secondary} wrap="truncate-end">
{ambientPrefix}
{ambientText}
</Text>
</Box>
@@ -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 (
<Text color={theme.status.warning}> Awaiting user approval...</Text>
);
return <Text color={theme.status.warning}> Awaiting approval</Text>;
}
return null;
};
@@ -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<LoadingIndicatorProps> = ({
currentLoadingPhrase,
wittyPhrase,
wittyPosition = 'inline',
elapsedTime,
inline = false,
rightContent,
@@ -57,9 +61,11 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
: 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<LoadingIndicatorProps> = ({
? `esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)}`
: null;
const wittyPhraseNode =
forceRealStatusOnly &&
wittyPosition === 'inline' &&
wittyPhrase &&
primaryText ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary}>{wittyPhrase}</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
@@ -91,6 +107,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
{primaryText}
</Text>
)}
{wittyPhraseNode}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
@@ -129,6 +146,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
{primaryText}
</Text>
)}
{wittyPhraseNode}
{!isNarrow && cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
@@ -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[];
@@ -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,
};
};
@@ -30,14 +30,14 @@ const TestComponent = ({
loadingPhrasesMode?: LoadingPhrasesMode;
customPhrases?: string[];
}) => {
const phrase = usePhraseCycler(
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isActive,
isWaiting,
isInteractiveShellWaiting,
loadingPhrasesMode,
customPhrases,
);
return <Text>{phrase}</Text>;
return <Text>{currentTip || currentWittyPhrase}</Text>;
};
describe('usePhraseCycler', () => {
+22 -22
View File
@@ -31,7 +31,8 @@ export const usePhraseCycler = (
customPhrases?: string[],
maxLength?: number,
) => {
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
const [currentTip, setCurrentTip] = useState<string | undefined>(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 };
};