feat(cli): finalize stable footer UX and fix lint/tests

This commit is contained in:
Keith Guerin
2026-03-01 02:40:01 -08:00
parent e1e863dba2
commit acd24006b6
28 changed files with 484 additions and 498 deletions

View File

@@ -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,

View File

@@ -174,6 +174,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
currentTip: '',
currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,

View File

@@ -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 (
<Box flexDirection="row" justifyContent="flex-end" marginLeft={1}>
<Box
flexDirection="row"
justifyContent="flex-end"
marginLeft={1}
marginRight={1}
>
{isExperimentalLayout ? (
<ShortcutsHint />
) : (
@@ -273,8 +266,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
);
}
return (
<Box flexDirection="row" justifyContent="flex-end" marginLeft={1}>
<Text color={theme.text.secondary} wrap="truncate-end">
<Box
flexDirection="row"
justifyContent="flex-end"
marginLeft={1}
marginRight={1}
>
<Text
color={theme.text.secondary}
wrap="truncate-end"
italic={ambientText === uiState.currentWittyPhrase}
>
{ambientText}
</Text>
</Box>
@@ -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 (
<Box flexDirection="row" alignItems="center">
<Box marginRight={1}>
<GeminiRespondingSpinner nonRespondingDisplay={hookIcon} />
<GeminiRespondingSpinner
nonRespondingDisplay={hasUserHooks ? hookIcon : undefined}
isHookActive={hasUserHooks}
/>
</Box>
<Text color={theme.text.primary} italic wrap="truncate-end">
<HookStatusDisplay activeHooks={uiState.activeHooks} />
</Text>
{!hasUserHooks && showWit && uiState.currentWittyPhrase && (
<Box marginLeft={1}>
<Text color={theme.text.secondary} italic>
{uiState.currentWittyPhrase}
</Text>
</Box>
)}
</Box>
);
}
@@ -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 <Text color={theme.status.warning}> Awaiting approval</Text>;
return <Text color={theme.status.warning}> Action required</Text>;
}
return null;
};
const statusNode = renderStatusNode();
const hasStatusMessage = Boolean(statusNode) || hasToast;
return (
<Box
flexDirection="column"
@@ -365,6 +386,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showUiDetails && <TodoTray />}
<Box width="100%" flexDirection="column">
{showUiDetails && hasStatusMessage && <HorizontalLine />}
{!isExperimentalLayout ? (
<Box width="100%" flexDirection="column">
<Box
@@ -390,7 +412,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: 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 }) => {
</Box>
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
{showUiDetails && (
<Box
justifyContent={
@@ -580,8 +601,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
</Box>
) : (
<Box width="100%" flexDirection="column">
{showUiDetails && newLayoutSetting === 'new' && <HorizontalLine />}
{showUiDetails && (
<Box
width="100%"
@@ -608,7 +627,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexShrink={0}
marginLeft={1}
>
{renderStatusNode()}
{statusNode}
</Box>
<Box flexShrink={0} marginLeft={2}>
{renderAmbientNode()}
@@ -619,7 +638,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
)}
{showUiDetails && newLayoutSetting === 'new_divider_down' && (
<HorizontalLine />
<HorizontalLine color={theme.ui.dark} dim />
)}
{showUiDetails && (

View File

@@ -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 (
<GeminiSpinner
spinnerType={spinnerType}

View File

@@ -63,7 +63,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
return (
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
<Box
flexDirection="column"
key={itemForDisplay.id}
width={terminalWidth}
paddingX={0}
>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />

View File

@@ -64,4 +64,18 @@ describe('<HookStatusDisplay />', () => {
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(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Working...');
unmount();
});
});

View File

@@ -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<HookStatusDisplayProps> = ({
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 color="inherit">{text}</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 color="inherit">{text}</Text>;
}
// If only system/extension hooks are running, show a generic message.
return <Text color="inherit">{GENERIC_WORKING_LABEL}</Text>;
};

View File

@@ -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
});

View File

@@ -209,7 +209,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<InputPromptProps> = ({
}
}
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<InputPromptProps> = ({
}
const suggestionsNode = shouldShowSuggestions ? (
<Box paddingRight={2}>
<Box paddingX={0}>
<SuggestionsDisplay
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}
@@ -1451,6 +1451,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
borderRight={false}
borderColor={borderColor}
width={terminalWidth}
marginLeft={0}
flexDirection="row"
alignItems="flex-start"
height={0}
@@ -1460,11 +1461,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundBaseColor={theme.background.input}
backgroundOpacity={1}
useBackgroundColor={useBackgroundColor}
marginX={0}
>
<Box
flexGrow={1}
flexDirection="row"
paddingX={1}
backgroundColor={
useBackgroundColor ? theme.background.input : undefined
}
borderColor={borderColor}
borderStyle={useLineFallback ? 'round' : undefined}
borderTop={false}
@@ -1472,29 +1476,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
borderLeft={!useBackgroundColor}
borderRight={!useBackgroundColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
<Box flexDirection="row">
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
'>'
)}{' '}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
@@ -1673,6 +1679,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
borderRight={false}
borderColor={borderColor}
width={terminalWidth}
marginLeft={0}
flexDirection="row"
alignItems="flex-start"
height={0}

View File

@@ -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<LoadingIndicatorProps> = ({
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<LoadingIndicatorProps> = ({
? 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<LoadingIndicatorProps> = ({
: null;
const wittyPhraseNode =
forceRealStatusOnly &&
wittyPosition === 'inline' &&
wittyPhrase &&
primaryText ? (
showWit && wittyPhrase && primaryText === GENERIC_WORKING_LABEL ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary}>{wittyPhrase}</Text>
<Text color={theme.text.secondary} italic>
{wittyPhrase}
</Text>
</Box>
) : null;
@@ -98,11 +94,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text
color={theme.text.primary}
italic
wrap={isNarrow ? 'wrap' : 'truncate-end'}
>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
@@ -137,11 +129,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text
color={theme.text.primary}
italic
wrap={isNarrow ? 'wrap' : 'truncate-end'}
>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>

View File

@@ -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

View File

@@ -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
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;

View File

@@ -52,9 +52,9 @@ export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
}
return (
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
<Box width="100%" marginBottom={1} flexDirection="column">
{summary && (
<Box paddingLeft={2}>
<Box paddingLeft={1}>
<Text color={theme.text.primary} bold italic>
{summary}
</Text>

View File

@@ -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<HalfLinePaddedBoxProps> = ({
backgroundBaseColor,
backgroundOpacity,
children,
marginX = 0,
}) => {
const { terminalWidth } = useUIState();
const terminalBg = theme.background.primary || 'black';
@@ -80,6 +86,8 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
}
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<HalfLinePaddedBoxProps> = ({
flexShrink={0}
>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
<Text color={terminalBg}>
{marginSpaces}
<Text color={backgroundColor}>{'▄'.repeat(barWidth)}</Text>
{marginSpaces}
</Text>
</Box>
<Box
width={terminalWidth}
width={barWidth}
marginLeft={marginX}
flexDirection="column"
alignItems="stretch"
backgroundColor={backgroundColor}
@@ -102,7 +115,11 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
{children}
</Box>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
<Text color={terminalBg}>
{marginSpaces}
<Text color={backgroundColor}>{'▀'.repeat(barWidth)}</Text>
{marginSpaces}
</Text>
</Box>
</Box>
);
@@ -115,17 +132,27 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
alignItems="stretch"
minHeight={1}
flexShrink={0}
backgroundColor={backgroundColor}
>
<Box width={terminalWidth} flexDirection="row">
<Text backgroundColor={backgroundColor} color={terminalBg}>
{'▀'.repeat(terminalWidth)}
<Text color={terminalBg}>
{marginSpaces}
<Text backgroundColor={backgroundColor}>{'▀'.repeat(barWidth)}</Text>
{marginSpaces}
</Text>
</Box>
{children}
<Box
width={barWidth}
marginLeft={marginX}
backgroundColor={backgroundColor}
flexDirection="column"
>
{children}
</Box>
<Box width={terminalWidth} flexDirection="row">
<Text color={terminalBg} backgroundColor={backgroundColor}>
{'▄'.repeat(terminalWidth)}
<Text color={terminalBg}>
{marginSpaces}
<Text backgroundColor={backgroundColor}>{'▄'.repeat(barWidth)}</Text>
{marginSpaces}
</Text>
</Box>
</Box>

View File

@@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js';
interface HorizontalLineProps {
color?: string;
dim?: boolean;
}
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
color = theme.border.default,
dim = false,
}) => (
<Box
width="100%"
@@ -23,5 +25,6 @@ export const HorizontalLine: React.FC<HorizontalLineProps> = ({
borderLeft={false}
borderRight={false}
borderColor={color}
borderDimColor={dim}
/>
);

View File

@@ -2,7 +2,7 @@
exports[`<HalfLinePaddedBox /> > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
"▄▄▄▄▄▄▄▄▄▄
Content
Content
▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -17,9 +17,16 @@ exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is fals
"
`;
exports[`<HalfLinePaddedBox /> > renders only background without blocks when Apple Terminal is detected 1`] = `
".
Content
.
"
`;
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
"▀▀▀▀▀▀▀▀▀▀
Content
Content
▄▄▄▄▄▄▄▄▄▄
"
`;

View File

@@ -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)"`;

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,
loadingPhraseLayout: LoadingPhrasesMode = 'all_inline',
showTips: boolean = true,
showWit: boolean = true,
initialErrorVerbosity: 'low' | 'full' = 'full',
) => {
let hookResult: ReturnType<typeof useLoadingIndicator>;
@@ -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(
<TestComponent
mode={loadingPhraseLayout}
showTips={showTips}
showWit={showWit}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
@@ -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();
});
});

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,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,
);

View File

@@ -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 <Text>{currentTip || currentWittyPhrase}</Text>;
// For tests, we'll combine them to verify existence
return (
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
);
};
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(
<TestComponent isActive={true} isWaiting={false} />,
);
@@ -86,7 +91,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -108,7 +113,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -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(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
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(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
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(
<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 () => {
@@ -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(
<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();
});
@@ -293,7 +280,8 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={config.isActive}
isWaiting={false}
loadingPhraseLayout="wit_inline"
showTips={false}
showWit={true}
customPhrases={config.customPhrases}
/>
);
@@ -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(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={false}
showWit={true}
customPhrases={[]}
/>,
);
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(
<TestComponent isActive={true} isWaiting={false} />,
);
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(<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,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<string | undefined>(undefined);
const [currentWittyPhrase, setCurrentWittyPhrase] = 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 lastChangeTimeRef = useRef<number>(0);
const lastSelectedTipRef = useRef<string | undefined>(undefined);
const lastSelectedWittyPhraseRef = useRef<string | undefined>(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 };
};

View File

@@ -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...';

View File

@@ -516,6 +516,7 @@ export interface PermissionConfirmationRequest {
export interface ActiveHook {
name: string;
eventName: string;
source?: string;
index?: number;
total?: number;
}