fix(ui): unify hook status into LoadingIndicator for stable replacement UX

This commit is contained in:
Keith Guerin
2026-03-13 23:21:33 -07:00
parent aded7baaaa
commit 5e7d11641e
3 changed files with 54 additions and 42 deletions

View File

@@ -37,8 +37,6 @@ import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { HookStatusDisplay } from './HookStatusDisplay.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
@@ -279,7 +277,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const showRow1 = showUiDetails || showRow1_MiniMode;
const showRow2 = showUiDetails || showRow2_MiniMode;
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
const renderAmbientNode = () => {
@@ -311,41 +308,47 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
};
const renderStatusNode = () => {
if (!hasUserHooks && !showLoadingIndicator) return null;
if (hasUserHooks) {
const activeHook = userHooks[0];
const hookIcon = activeHook?.eventName?.startsWith('After') ? '↩' : '↪';
const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userHooks.map((h) => {
let name = h.name;
if (h.index && h.total && h.total > 1) name += ` (${h.index}/${h.total})`;
return name;
});
const hookText = `${label}: ${displayNames.join(', ')}`;
return (
<Box flexDirection="row" alignItems="center" columnGap={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={hookIcon}
isHookActive={true}
/>
<HookStatusDisplay activeHooks={userHooks} />
{showWit && uiState.currentWittyPhrase && (
<Text color={theme.text.secondary} dimColor italic>
{uiState.currentWittyPhrase}
</Text>
)}
</Box>
);
}
if (showLoadingIndicator) {
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
currentLoadingPhrase={hookText}
spinnerIcon={hookIcon}
isHookActive={true}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return null;
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
};
const statusNode = renderStatusNode();
@@ -355,22 +358,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row" columnGap={1}>
{showMinimalInlineLoading && (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={true}
/>
)}
{hasUserHooks && (
<Box flexDirection="row" columnGap={1}>
<GeminiRespondingSpinner isHookActive={true} />
<HookStatusDisplay activeHooks={userHooks} />
</Box>
)}
{renderStatusNode()}
{showMinimalBleedThroughRow && (
<Box>
{miniMode_ShowApprovalMode && modeContentObj && (

View File

@@ -448,4 +448,20 @@ describe('<LoadingIndicator />', () => {
unmount();
});
});
it('should use spinnerIcon when provided', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
spinnerIcon: '?',
};
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('⠏');
});
});

View File

@@ -29,6 +29,8 @@ interface LoadingIndicatorProps {
thoughtLabel?: string;
showCancelAndTimer?: boolean;
forceRealStatusOnly?: boolean;
spinnerIcon?: string;
isHookActive?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@@ -42,6 +44,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
thoughtLabel,
showCancelAndTimer = true,
forceRealStatusOnly = false,
spinnerIcon,
isHookActive = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@@ -91,10 +95,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
@@ -133,10 +139,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (