From f27796172fd386d929a09ce01352804b9aeefd7c Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Mon, 2 Mar 2026 15:04:15 -0800 Subject: [PATCH] feat(cli): decouple Tips/Wit timers and implement width-aware layout fallbacks --- packages/cli/src/ui/components/Composer.tsx | 37 +++-- .../src/ui/components/LoadingIndicator.tsx | 4 +- .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 2 +- packages/cli/src/ui/hooks/usePhraseCycler.ts | 136 +++++++++++------- 4 files changed, 112 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 174746d314..bb9b504bfc 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -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 ? ( - - ) : ( - showShortcutsHint && - )} + ); } diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index c883c95938..5eff010cb6 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -81,8 +81,8 @@ export const LoadingIndicator: React.FC = ({ wittyPhrase && primaryText === GENERIC_WORKING_LABEL ? ( - - {wittyPhrase} + + {wittyPhrase} :) ) : null; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index d74b8f060a..ab7431da7a 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -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 () => { diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 68ec573214..1b82336afe 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -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(null); - const lastChangeTimeRef = useRef(0); + const tipIntervalRef = useRef(null); + const wittyIntervalRef = useRef(null); + const lastTipChangeTimeRef = useRef(0); + const lastWittyChangeTimeRef = useRef(0); const lastSelectedTipRef = useRef(undefined); const lastSelectedWittyPhraseRef = useRef(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,