feat(cli): decouple Tips/Wit timers and implement width-aware layout fallbacks

This commit is contained in:
Keith Guerin
2026-03-02 15:04:15 -08:00
parent 7ce6bf7b28
commit f27796172f
4 changed files with 112 additions and 67 deletions

View File

@@ -206,11 +206,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
showMinimalBleedThroughRow ||
showShortcutsHint);
const ambientText = isInteractiveShellWaiting
? undefined
: (showTips ? uiState.currentTip : undefined) ||
(showWit ? uiState.currentWittyPhrase : undefined);
let estimatedStatusLength = 0;
if (
isExperimentalLayout &&
@@ -238,6 +233,32 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
estimatedStatusLength = 20; // "↑ Action required"
}
const ambientText = (() => {
if (isInteractiveShellWaiting) return undefined;
// Try Tip first
if (showTips && uiState.currentTip) {
if (
estimatedStatusLength + uiState.currentTip.length + 5 <=
terminalWidth
) {
return uiState.currentTip;
}
}
// Fallback to Wit
if (showWit && uiState.currentWittyPhrase) {
if (
estimatedStatusLength + uiState.currentWittyPhrase.length + 5 <=
terminalWidth
) {
return uiState.currentWittyPhrase;
}
}
return undefined;
})();
const estimatedAmbientLength = ambientText?.length || 0;
const willCollideAmbient =
estimatedStatusLength + estimatedAmbientLength + 5 > terminalWidth;
@@ -265,11 +286,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
marginLeft={1}
marginRight={1}
>
{isExperimentalLayout ? (
<ShortcutsHint />
) : (
showShortcutsHint && <ShortcutsHint />
)}
<ShortcutsHint />
</Box>
);
}

View File

@@ -81,8 +81,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
wittyPhrase &&
primaryText === GENERIC_WORKING_LABEL ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} italic>
{wittyPhrase}
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase} :)
</Text>
</Box>
) : null;

View File

@@ -251,7 +251,7 @@ describe('usePhraseCycler', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalledOnce();
expect(clearIntervalSpy).toHaveBeenCalled();
});
it('should use custom phrases when provided', async () => {

View File

@@ -8,7 +8,8 @@ import { useState, useEffect, useRef } from 'react';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.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 =
'! Shell awaiting input (Tab to focus)';
@@ -39,18 +40,29 @@ export const usePhraseCycler = (
string | undefined
>(undefined);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastChangeTimeRef = useRef<number>(0);
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;
}
};
clearTimers();
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
@@ -66,69 +78,85 @@ export const usePhraseCycler = (
? customPhrases
: WITTY_LOADING_PHRASES;
const setRandomPhrases = (force: boolean = false) => {
const setRandomTip = (force: boolean = false) => {
if (!showTips) {
setCurrentTipState(undefined);
lastSelectedTipRef.current = undefined;
return;
}
const now = Date.now();
if (
!force &&
now - lastChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
(lastSelectedTipRef.current || lastSelectedWittyPhraseRef.current)
now - lastTipChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
lastSelectedTipRef.current
) {
// Sync state if it was cleared by inactivation.
setCurrentTipState(lastSelectedTipRef.current);
return;
}
const filteredTips =
maxLength !== undefined
? INFORMATIVE_TIPS.filter((p) => p.length <= maxLength)
: INFORMATIVE_TIPS;
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 adjustedMaxLength = maxLength;
const filteredWitty =
maxLength !== undefined
? wittyPhrasesList.filter((p) => p.length <= maxLength)
: wittyPhrasesList;
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 {
setCurrentTipState(undefined);
lastSelectedTipRef.current = undefined;
if (filteredWitty.length > 0) {
const selected =
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
setCurrentWittyPhraseState(selected);
lastSelectedWittyPhraseRef.current = selected;
lastWittyChangeTimeRef.current = now;
}
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 initial random phrases or resume previous ones
setRandomPhrases(false);
setRandomTip(false);
setRandomWitty(false);
phraseIntervalRef.current = setInterval(() => {
setRandomPhrases(true); // Force change on interval
}, PHRASE_CHANGE_INTERVAL_MS);
if (showTips) {
tipIntervalRef.current = setInterval(() => {
setRandomTip(true);
}, PHRASE_CHANGE_INTERVAL_MS);
}
return () => {
if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null;
}
};
if (showWit) {
wittyIntervalRef.current = setInterval(() => {
setRandomWitty(true);
}, WITTY_PHRASE_CHANGE_INTERVAL_MS);
}
return clearTimers;
}, [
isActive,
isWaiting,