diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 5bdd175a2f..2e53670c31 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -83,22 +83,17 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); - it('should have loadingPhraseLayout enum property', () => { - const definition = - getSettingsSchema().ui?.properties?.loadingPhraseLayout; - expect(definition).toBeDefined(); - expect(definition?.type).toBe('enum'); - expect(definition?.default).toBe('all_inline'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(definition?.options?.map((o: any) => o.value)).toEqual([ - 'none', - 'tips', - 'wit_status', - 'wit_inline', - 'wit_ambient', - 'all_inline', - 'all_ambient', - ]); + it('should have showTips property', () => { + const showTips = getSettingsSchema().ui?.properties?.showTips; + expect(showTips).toBeDefined(); + expect(showTips?.type).toBe('boolean'); + }); + + it('should have showWit property', () => { + const showWit = getSettingsSchema().ui?.properties?.showWit; + expect(showWit).toBeDefined(); + expect(showWit?.type).toBe('boolean'); + expect(showWit?.default).toBe(true); }); it('should have errorVerbosity enum property', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 332fb827bf..4933de74a9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -633,25 +633,26 @@ const SETTINGS_SCHEMA = { { value: 'new_divider_down', label: 'New Layout (Divider Down)' }, ], }, - loadingPhraseLayout: { - type: 'enum', - label: 'Loading Phrase Layout', + showTips: { + type: 'boolean', + label: 'Show Tips', category: 'UI', requiresRestart: false, - default: 'all_inline', + default: true, description: - 'Control which loading phrases are shown and where they appear.', + 'Show informative tips on the right side of the status line.', showInDialog: true, - options: [ - { value: 'none', label: 'None' }, - { value: 'tips', label: 'Tips Only (at right)' }, - { value: 'wit_status', label: 'Wit Only (in status slot)' }, - { value: 'wit_inline', label: 'Wit Only (after status)' }, - { value: 'wit_ambient', label: 'Wit Only (at right)' }, - { value: 'all_inline', label: 'Tips at right, Wit inline' }, - { value: 'all_ambient', label: 'Tips and Wit at right' }, - ], }, + showWit: { + type: 'boolean', + label: 'Show Witty Phrases', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show witty phrases while waiting.', + showInDialog: true, + }, + showMemoryUsage: { type: 'boolean', label: 'Show Memory Usage', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c2cfb669c7..8e84c4c267 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2102,7 +2102,8 @@ Logging in with Google... Restarting Gemini CLI to continue. streamingState, shouldShowFocusHint, retryStatus, - loadingPhraseLayout: settings.merged.ui.loadingPhraseLayout, + showTips: settings.merged.ui.showTips, + showWit: settings.merged.ui.showWit, customWittyPhrases: settings.merged.ui.customWittyPhrases, errorVerbosity: settings.merged.ui.errorVerbosity, maxLength, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 06429e1b85..ced4d3497f 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -174,6 +174,8 @@ const createMockUIState = (overrides: Partial = {}): UIState => isFocused: true, thought: '', currentLoadingPhrase: '', + currentTip: '', + currentWittyPhrase: '', elapsedTime: 0, ctrlCPressedOnce: false, ctrlDPressedOnce: false, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 02b3a13b8a..849187ce64 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -42,6 +42,7 @@ import { TodoTray } from './messages/Todo.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; import { isContextUsageHigh } from '../utils/contextUsage.js'; import { theme } from '../semantic-colors.js'; +import { GENERIC_WORKING_LABEL } from '../textConstants.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -59,14 +60,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const isAlternateBuffer = useAlternateBuffer(); const { showApprovalModeIndicator } = uiState; const newLayoutSetting = settings.merged.ui.newFooterLayout; - const { loadingPhraseLayout } = settings.merged.ui; - const wittyPosition: 'status' | 'inline' | 'ambient' = - loadingPhraseLayout === 'wit_status' - ? 'status' - : loadingPhraseLayout === 'wit_inline' || - loadingPhraseLayout === 'all_inline' - ? 'inline' - : 'ambient'; + const { showTips, showWit } = settings.merged.ui; + const isExperimentalLayout = newLayoutSetting !== 'legacy'; const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; @@ -205,15 +200,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const ambientText = isInteractiveShellWaiting ? undefined - : (loadingPhraseLayout === 'tips' || - loadingPhraseLayout === 'all_inline' || - loadingPhraseLayout === 'all_ambient' - ? uiState.currentTip - : undefined) || - (loadingPhraseLayout === 'wit_ambient' || - loadingPhraseLayout === 'all_ambient' - ? uiState.currentWittyPhrase - : undefined); + : (showTips ? uiState.currentTip : undefined) || + (showWit ? uiState.currentWittyPhrase : undefined); let estimatedStatusLength = 0; if ( @@ -232,14 +220,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { .join(', '); estimatedStatusLength = hookLabel.length + hookNames.length + 10; // +10 for spinner and spacing } else if (showLoadingIndicator) { - const thoughtText = uiState.thought?.subject || 'Waiting for model...'; + const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL; const inlineWittyLength = - wittyPosition === 'inline' && uiState.currentWittyPhrase + showWit && uiState.currentWittyPhrase ? uiState.currentWittyPhrase.length + 1 : 0; estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength; // Spinner(3) + timer(15) + padding + witty } else if (hasPendingActionRequired) { - estimatedStatusLength = 25; // "↑ Awaiting approval" + estimatedStatusLength = 20; // "↑ Action required" } const estimatedAmbientLength = ambientText?.length || 0; @@ -252,8 +240,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { isExperimentalLayout && uiState.streamingState !== StreamingState.Idle && !hasPendingActionRequired && + (showTips || showWit) && ambientText && - loadingPhraseLayout !== 'none' && !willCollideAmbient && !isNarrow; @@ -263,7 +251,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { if (!showAmbientLine) { if (willCollideShortcuts) return null; // If even the shortcut hint would collide, hide completely so Status takes absolute precedent return ( - + {isExperimentalLayout ? ( ) : ( @@ -273,8 +266,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ); } return ( - - + + {ambientText} @@ -292,14 +294,29 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const activeHook = uiState.activeHooks[0]; const hookIcon = activeHook?.eventName?.startsWith('After') ? '↩' : '↪'; + const USER_HOOK_SOURCES = ['user', 'project', 'runtime']; + const hasUserHooks = uiState.activeHooks.some( + (h) => !h.source || USER_HOOK_SOURCES.includes(h.source), + ); + return ( - + + {!hasUserHooks && showWit && uiState.currentWittyPhrase && ( + + + {uiState.currentWittyPhrase} + + + )} ); } @@ -316,12 +333,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { currentLoadingPhrase={ uiState.currentLoadingPhrase?.includes('Tab to focus') ? uiState.currentLoadingPhrase - : !isExperimentalLayout && loadingPhraseLayout !== 'none' + : !isExperimentalLayout && (showTips || showWit) ? uiState.currentLoadingPhrase : isExperimentalLayout && uiState.streamingState === StreamingState.Responding && !uiState.thought - ? 'Waiting for model...' + ? GENERIC_WORKING_LABEL : undefined } thoughtLabel={ @@ -332,17 +349,21 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { elapsedTime={uiState.elapsedTime} forceRealStatusOnly={isExperimentalLayout} showCancelAndTimer={!isExperimentalLayout} + showTips={showTips} + showWit={showWit} wittyPhrase={uiState.currentWittyPhrase} - wittyPosition={wittyPosition} /> ); } if (hasPendingActionRequired) { - return ↑ Awaiting approval; + return ↑ Action required; } return null; }; + const statusNode = renderStatusNode(); + const hasStatusMessage = Boolean(statusNode) || hasToast; + return ( { {showUiDetails && } + {showUiDetails && hasStatusMessage && } {!isExperimentalLayout ? ( { : uiState.thought } currentLoadingPhrase={ - loadingPhraseLayout === 'none' + !showTips && !showWit ? undefined : uiState.currentLoadingPhrase } @@ -433,7 +455,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.thought } currentLoadingPhrase={ - loadingPhraseLayout === 'none' + !showTips && !showWit ? undefined : uiState.currentLoadingPhrase } @@ -494,7 +516,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} {showShortcutsHelp && } - {showUiDetails && } {showUiDetails && ( { ) : ( - {showUiDetails && newLayoutSetting === 'new' && } - {showUiDetails && ( { flexShrink={0} marginLeft={1} > - {renderStatusNode()} + {statusNode} {renderAmbientNode()} @@ -619,7 +638,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} {showUiDetails && newLayoutSetting === 'new_divider_down' && ( - + )} {showUiDetails && ( diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx index 2e6821355f..793c35fefe 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -23,14 +23,22 @@ interface GeminiRespondingSpinnerProps { */ nonRespondingDisplay?: string; spinnerType?: SpinnerName; + /** + * If true, we prioritize showing the nonRespondingDisplay (hook icon) + * even if the state is Responding. + */ + isHookActive?: boolean; } export const GeminiRespondingSpinner: React.FC< GeminiRespondingSpinnerProps -> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => { +> = ({ nonRespondingDisplay, spinnerType = 'dots', isHookActive = false }) => { const streamingState = useStreamingContext(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); - if (streamingState === StreamingState.Responding) { + + // If a hook is active, we want to show the hook icon (nonRespondingDisplay) + // to be consistent, instead of the rainbow spinner which means "Gemini is talking". + if (streamingState === StreamingState.Responding && !isHookActive) { return ( = ({ const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); return ( - + {/* Render standard message types */} {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( diff --git a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx index fbf9ccb555..e2f39c301c 100644 --- a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx @@ -64,4 +64,18 @@ describe('', () => { expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); + + it('should show generic message when only system/extension hooks are active', async () => { + const props = { + activeHooks: [ + { name: 'ext-hook', eventName: 'BeforeAgent', source: 'extensions' }, + ], + }; + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Working...'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/HookStatusDisplay.tsx b/packages/cli/src/ui/components/HookStatusDisplay.tsx index c646529b90..8a464b9149 100644 --- a/packages/cli/src/ui/components/HookStatusDisplay.tsx +++ b/packages/cli/src/ui/components/HookStatusDisplay.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { Text } from 'ink'; import { type ActiveHook } from '../types.js'; +import { GENERIC_WORKING_LABEL } from '../textConstants.js'; interface HookStatusDisplayProps { activeHooks: ActiveHook[]; @@ -19,16 +20,27 @@ export const HookStatusDisplay: React.FC = ({ return null; } - const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; - const displayNames = activeHooks.map((hook) => { - let name = hook.name; - if (hook.index && hook.total && hook.total > 1) { - name += ` (${hook.index}/${hook.total})`; - } - return name; - }); + // Define which hook sources are considered "user" hooks that should be shown explicitly. + const USER_HOOK_SOURCES = ['user', 'project', 'runtime']; - const text = `${label}: ${displayNames.join(', ')}`; + const userHooks = activeHooks.filter( + (h) => !h.source || USER_HOOK_SOURCES.includes(h.source), + ); - return {text}; + if (userHooks.length > 0) { + const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; + const displayNames = userHooks.map((hook) => { + let name = hook.name; + if (hook.index && hook.total && hook.total > 1) { + name += ` (${hook.index}/${hook.total})`; + } + return name; + }); + + const text = `${label}: ${displayNames.join(', ')}`; + return {text}; + } + + // If only system/extension hooks are running, show a generic message. + return {GENERIC_WORKING_LABEL}; }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 65a4440d77..40c657af4e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -3340,28 +3340,28 @@ describe('InputPrompt', () => { name: 'first line, first char', relX: 0, relY: 0, - mouseCol: 4, + mouseCol: 6, mouseRow: 2, }, { name: 'first line, middle char', relX: 6, relY: 0, - mouseCol: 10, + mouseCol: 12, mouseRow: 2, }, { name: 'second line, first char', relX: 0, relY: 1, - mouseCol: 4, + mouseCol: 6, mouseRow: 3, }, { name: 'second line, end char', relX: 5, relY: 1, - mouseCol: 9, + mouseCol: 11, mouseRow: 3, }, ])( @@ -3421,7 +3421,7 @@ describe('InputPrompt', () => { await act(async () => { // Click somewhere in the prompt - stdin.write(`\x1b[<0;5;2M`); + stdin.write(`\x1b[<0;9;2M`); }); await waitFor(() => { @@ -3621,6 +3621,7 @@ describe('InputPrompt', () => { }); // With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5) + // Actually with my change it should be even more offset. await act(async () => { stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2 }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d08a0bef74..a753ccde6b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -209,7 +209,7 @@ export const InputPrompt: React.FC = ({ setBannerVisible, }) => { const { stdout } = useStdout(); - const { merged: settings } = useSettings(); + const settings = useSettings(); const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); const { @@ -469,7 +469,7 @@ export const InputPrompt: React.FC = ({ } } - if (settings.experimental?.useOSC52Paste) { + if (settings.merged.experimental?.useOSC52Paste) { stdout.write('\x1b]52;c;?\x07'); } else { const textToInsert = await clipboardy.read(); @@ -1408,7 +1408,7 @@ export const InputPrompt: React.FC = ({ } const suggestionsNode = shouldShowSuggestions ? ( - + = ({ borderRight={false} borderColor={borderColor} width={terminalWidth} + marginLeft={0} flexDirection="row" alignItems="flex-start" height={0} @@ -1460,11 +1461,14 @@ export const InputPrompt: React.FC = ({ backgroundBaseColor={theme.background.input} backgroundOpacity={1} useBackgroundColor={useBackgroundColor} + marginX={0} > = ({ borderLeft={!useBackgroundColor} borderRight={!useBackgroundColor} > - - {shellModeActive ? ( - reverseSearchActive ? ( - - (r:){' '} - + + + {shellModeActive ? ( + reverseSearchActive ? ( + + (r:){' '} + + ) : ( + '!' + ) + ) : commandSearchActive ? ( + (r:) + ) : showYoloStyling ? ( + '*' ) : ( - '!' - ) - ) : commandSearchActive ? ( - (r:) - ) : showYoloStyling ? ( - '*' - ) : ( - '>' - )}{' '} - + '>' + )}{' '} + + {buffer.text.length === 0 && placeholder ? ( showCursor ? ( @@ -1673,6 +1679,7 @@ export const InputPrompt: React.FC = ({ borderRight={false} borderColor={borderColor} width={terminalWidth} + marginLeft={0} flexDirection="row" alignItems="flex-start" height={0} diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index eda956fd03..141196fd61 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -15,31 +15,32 @@ import { formatDuration } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; +import { GENERIC_WORKING_LABEL } from '../textConstants.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; wittyPhrase?: string; - wittyPosition?: 'status' | 'inline' | 'ambient'; + showWit?: boolean; + showTips?: boolean; elapsedTime: number; inline?: boolean; rightContent?: React.ReactNode; thought?: ThoughtSummary | null; thoughtLabel?: string; showCancelAndTimer?: boolean; - forceRealStatusOnly?: boolean; } export const LoadingIndicator: React.FC = ({ currentLoadingPhrase, wittyPhrase, - wittyPosition = 'inline', + showWit = true, + showTips: _showTips = true, elapsedTime, inline = false, rightContent, thought, thoughtLabel, showCancelAndTimer = true, - forceRealStatusOnly = false, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); @@ -60,12 +61,8 @@ export const LoadingIndicator: React.FC = ({ ? currentLoadingPhrase : thought?.subject ? (thoughtLabel ?? thought.subject) - : forceRealStatusOnly - ? wittyPosition === 'status' && wittyPhrase - ? wittyPhrase - : streamingState === StreamingState.Responding - ? 'Waiting for model...' - : undefined + : streamingState === StreamingState.Responding + ? GENERIC_WORKING_LABEL : currentLoadingPhrase; const thinkingIndicator = ''; @@ -76,12 +73,11 @@ export const LoadingIndicator: React.FC = ({ : null; const wittyPhraseNode = - forceRealStatusOnly && - wittyPosition === 'inline' && - wittyPhrase && - primaryText ? ( + showWit && wittyPhrase && primaryText === GENERIC_WORKING_LABEL ? ( - {wittyPhrase} + + {wittyPhrase} + ) : null; @@ -98,11 +94,7 @@ export const LoadingIndicator: React.FC = ({ /> {primaryText && ( - + {thinkingIndicator} {primaryText} @@ -137,11 +129,7 @@ export const LoadingIndicator: React.FC = ({ /> {primaryText && ( - + {thinkingIndicator} {primaryText} diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap index 452663d719..8e6533a556 100644 --- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap @@ -2,7 +2,6 @@ exports[`Composer > Snapshots > matches snapshot in idle state 1`] = ` " ShortcutsHint -──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator StatusDisplay InputPrompt: Type your message or @path/to/file Footer @@ -24,7 +23,6 @@ InputPrompt: Type your message or @path/to/file exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` " ShortcutsHint -──────────────────────────────────────── ApprovalModeIndicator StatusDisplay @@ -35,8 +33,8 @@ Footer `; exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` -" LoadingIndicator: Thinking -──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────────────────────────── + LoadingIndicator: Thinking ApprovalModeIndicator InputPrompt: Type your message or @path/to/file Footer diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 88a1b0486f..8886e9fda8 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -1,77 +1,98 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > second message -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > second message + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll - ... +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + (r:) Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll + ... " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll - llllllllllllllllllllllllllllllllllllllllllllllllll +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + (r:) Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll + llllllllllllllllllllllllllllllllllllllllllllllllll " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - git commit -m "feat: add search" in src/app +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + (r:) commit + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + git commit -m "feat: add search" in src/app " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - git commit -m "feat: add search" in src/app +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + (r:) commit + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + git commit -m "feat: add search" in src/app " `; exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Image ...reenshot2x.png] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Image ...reenshot2x.png] + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > @/path/to/screenshots/screenshot2x.png -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > @/path/to/screenshots/screenshot2x.png + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = ` +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = ` +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = ` +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -79,29 +100,29 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ! Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ! Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - * Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + * Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Type your message or @path/to/file + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 86882307e7..7ff8c8a646 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -52,9 +52,9 @@ export const ThinkingMessage: React.FC = ({ } return ( - + {summary && ( - + {summary} diff --git a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx index add5353245..725698ecef 100644 --- a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx +++ b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx @@ -32,6 +32,11 @@ export interface HalfLinePaddedBoxProps { */ useBackgroundColor?: boolean; + /** + * Optional horizontal margin. + */ + marginX?: number; + children: React.ReactNode; } @@ -52,6 +57,7 @@ const HalfLinePaddedBoxInternal: React.FC = ({ backgroundBaseColor, backgroundOpacity, children, + marginX = 0, }) => { const { terminalWidth } = useUIState(); const terminalBg = theme.background.primary || 'black'; @@ -80,6 +86,8 @@ const HalfLinePaddedBoxInternal: React.FC = ({ } const isITerm = isITerm2(); + const barWidth = Math.max(0, terminalWidth - marginX * 2); + const marginSpaces = ' '.repeat(marginX); if (isITerm) { return ( @@ -91,10 +99,15 @@ const HalfLinePaddedBoxInternal: React.FC = ({ flexShrink={0} > - {'▄'.repeat(terminalWidth)} + + {marginSpaces} + {'▄'.repeat(barWidth)} + {marginSpaces} + = ({ {children} - {'▀'.repeat(terminalWidth)} + + {marginSpaces} + {'▀'.repeat(barWidth)} + {marginSpaces} + ); @@ -115,17 +132,27 @@ const HalfLinePaddedBoxInternal: React.FC = ({ alignItems="stretch" minHeight={1} flexShrink={0} - backgroundColor={backgroundColor} > - - {'▀'.repeat(terminalWidth)} + + {marginSpaces} + {'▀'.repeat(barWidth)} + {marginSpaces} - {children} + + {children} + - - {'▄'.repeat(terminalWidth)} + + {marginSpaces} + {'▄'.repeat(barWidth)} + {marginSpaces} diff --git a/packages/cli/src/ui/components/shared/HorizontalLine.tsx b/packages/cli/src/ui/components/shared/HorizontalLine.tsx index 92935617a7..cdce88a4e5 100644 --- a/packages/cli/src/ui/components/shared/HorizontalLine.tsx +++ b/packages/cli/src/ui/components/shared/HorizontalLine.tsx @@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js'; interface HorizontalLineProps { color?: string; + dim?: boolean; } export const HorizontalLine: React.FC = ({ color = theme.border.default, + dim = false, }) => ( = ({ borderLeft={false} borderRight={false} borderColor={color} + borderDimColor={dim} /> ); diff --git a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap index dbb9af2991..95aa758fcf 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap @@ -2,7 +2,7 @@ exports[` > renders iTerm2-specific blocks when iTerm2 is detected 1`] = ` "▄▄▄▄▄▄▄▄▄▄ -Content +Content ▀▀▀▀▀▀▀▀▀▀ " `; @@ -17,9 +17,16 @@ exports[` > renders nothing when useBackgroundColor is fals " `; +exports[` > renders only background without blocks when Apple Terminal is detected 1`] = ` +". +Content +. +" +`; + exports[` > renders standard background and blocks when not iTerm2 1`] = ` "▀▀▀▀▀▀▀▀▀▀ -Content +Content ▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap index f42967127f..aa7429867f 100644 --- a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap +++ b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap @@ -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)"`; diff --git a/packages/cli/src/ui/hooks/useHookDisplayState.ts b/packages/cli/src/ui/hooks/useHookDisplayState.ts index 6c9e1811ad..c98bc7ba29 100644 --- a/packages/cli/src/ui/hooks/useHookDisplayState.ts +++ b/packages/cli/src/ui/hooks/useHookDisplayState.ts @@ -43,6 +43,7 @@ export const useHookDisplayState = () => { { name: payload.hookName, eventName: payload.eventName, + source: payload.source, index: payload.hookIndex, total: payload.totalHooks, }, diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index 63bdf4fa36..4ede770840 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -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; @@ -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( , @@ -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(); }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 1ecdf9e71f..e3c53cb59c 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -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, ); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 16334d1ccc..d74b8f060a 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -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 {currentTip || currentWittyPhrase}; + // For tests, we'll combine them to verify existence + return ( + {[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')} + ); }; 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( , ); @@ -86,7 +91,7 @@ describe('usePhraseCycler', () => { , ); }); @@ -108,7 +113,7 @@ describe('usePhraseCycler', () => { , ); }); @@ -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( - , + , ); 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( - , + , ); 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( , ); }); 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( - , - ); - }); - 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', () => { ); @@ -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( - , + , ); 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( - , - ); - 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(); - }); - 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(); - }); - 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()); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 7c936994fb..68ec573214 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -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(undefined); - const [currentWittyPhrase, setCurrentWittyPhrase] = useState< + const [currentTipState, setCurrentTipState] = useState( + undefined, + ); + const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState< string | undefined >(undefined); const phraseIntervalRef = useRef(null); - const hasShownFirstRequestTipRef = useRef(false); + const lastChangeTimeRef = useRef(0); + const lastSelectedTipRef = useRef(undefined); + const lastSelectedWittyPhraseRef = useRef(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 }; }; diff --git a/packages/cli/src/ui/textConstants.ts b/packages/cli/src/ui/textConstants.ts index a7ea77de79..db93005f03 100644 --- a/packages/cli/src/ui/textConstants.ts +++ b/packages/cli/src/ui/textConstants.ts @@ -18,3 +18,5 @@ export const REDIRECTION_WARNING_NOTE_TEXT = export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: " export const REDIRECTION_WARNING_TIP_TEXT = 'Toggle auto-edit (Shift+Tab) to allow redirection in the future.'; + +export const GENERIC_WORKING_LABEL = 'Working...'; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 55048ef6bc..d8a3c0e710 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -516,6 +516,7 @@ export interface PermissionConfirmationRequest { export interface ActiveHook { name: string; eventName: string; + source?: string; index?: number; total?: number; } diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 00909094ce..b7a720e80e 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -302,6 +302,7 @@ export class HookEventHandler { coreEvents.emitHookStart({ hookName: this.getHookName(config), eventName, + source: config.source, hookIndex: index + 1, totalHooks: plan.hookConfigs.length, }); diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 159dde2a6d..1bf1eda5e2 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -88,9 +88,12 @@ export interface HookPayload { * Payload for the 'hook-start' event. */ export interface HookStartPayload extends HookPayload { + /** + * The source of the hook configuration. + */ + source?: string; /** * The 1-based index of the current hook in the execution sequence. - * Used for progress indication (e.g. "Hook 1/3"). */ hookIndex?: number; /**