Skip to content

Commit d53f7e7

Browse files
feat: custom loading phrase when interactive shell requires input (#12535)
1 parent 92226c0 commit d53f7e7

13 files changed

+416
-103
lines changed

packages/cli/src/ui/AppContainer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
713713
handleApprovalModeChange,
714714
activePtyId,
715715
loopDetectionConfirmationRequest,
716+
lastOutputTime,
716717
} = useGeminiStream(
717718
config.getGeminiClient(),
718719
historyManager.history,
@@ -1112,6 +1113,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
11121113
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
11131114
streamingState,
11141115
settings.merged.ui?.customWittyPhrases,
1116+
!!activePtyId && !embeddedShellFocused,
1117+
lastOutputTime,
11151118
);
11161119

11171120
const handleGlobalKeypress = useCallback(

packages/cli/src/ui/components/LoadingIndicator.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
1414
import { formatDuration } from '../utils/formatters.js';
1515
import { useTerminalSize } from '../hooks/useTerminalSize.js';
1616
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
17+
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
1718

1819
interface LoadingIndicatorProps {
1920
currentLoadingPhrase?: string;
@@ -36,7 +37,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
3637
return null;
3738
}
3839

39-
const primaryText = thought?.subject || currentLoadingPhrase;
40+
// Prioritize the interactive shell waiting phrase over the thought subject
41+
// because it conveys an actionable state for the user (waiting for input).
42+
const primaryText =
43+
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
44+
? currentLoadingPhrase
45+
: thought?.subject || currentLoadingPhrase;
4046

4147
const cancelAndTimerContent =
4248
streamingState !== StreamingState.WaitingForConfirmation

packages/cli/src/ui/components/messages/ShellToolMessage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { Box, Text, type DOMElement } from 'ink';
99
import { ToolCallStatus } from '../../types.js';
1010
import { ShellInputPrompt } from '../ShellInputPrompt.js';
1111
import { StickyHeader } from '../StickyHeader.js';
12-
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
12+
import {
13+
SHELL_COMMAND_NAME,
14+
SHELL_NAME,
15+
SHELL_FOCUS_HINT_DELAY_MS,
16+
} from '../../constants.js';
1317
import { theme } from '../../semantic-colors.js';
1418
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
1519
import { useUIActions } from '../../contexts/UIActionsContext.js';
@@ -104,7 +108,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
104108

105109
const timer = setTimeout(() => {
106110
setShowFocusHint(true);
107-
}, 5000);
111+
}, SHELL_FOCUS_HINT_DELAY_MS);
108112

109113
return () => clearTimeout(timer);
110114
}, [lastUpdateTime]);

packages/cli/src/ui/components/messages/ToolMessage.tsx

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*/
66

77
import type React from 'react';
8-
import { Box } from 'ink';
8+
import { useState, useEffect } from 'react';
9+
import { Box, Text } from 'ink';
910
import type { IndividualToolCallDisplay } from '../../types.js';
1011
import { StickyHeader } from '../StickyHeader.js';
1112
import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -14,7 +15,17 @@ import {
1415
ToolInfo,
1516
TrailingIndicator,
1617
type TextEmphasis,
18+
STATUS_INDICATOR_WIDTH,
1719
} from './ToolShared.js';
20+
import {
21+
SHELL_COMMAND_NAME,
22+
SHELL_FOCUS_HINT_DELAY_MS,
23+
} from '../../constants.js';
24+
import { theme } from '../../semantic-colors.js';
25+
import type { Config } from '@google/gemini-cli-core';
26+
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
27+
import { ToolCallStatus } from '../../types.js';
28+
import { ShellInputPrompt } from '../ShellInputPrompt.js';
1829

1930
export type { TextEmphasis };
2031

@@ -26,6 +37,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
2637
isFirst: boolean;
2738
borderColor: string;
2839
borderDimColor: boolean;
40+
activeShellPtyId?: number | null;
41+
embeddedShellFocused?: boolean;
42+
ptyId?: number;
43+
config?: Config;
2944
}
3045

3146
export const ToolMessage: React.FC<ToolMessageProps> = ({
@@ -40,41 +55,96 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
4055
isFirst,
4156
borderColor,
4257
borderDimColor,
43-
}) => (
44-
<Box flexDirection="column" width={terminalWidth}>
45-
<StickyHeader
46-
width={terminalWidth}
47-
isFirst={isFirst}
48-
borderColor={borderColor}
49-
borderDimColor={borderDimColor}
50-
>
51-
<ToolStatusIndicator status={status} name={name} />
52-
<ToolInfo
53-
name={name}
54-
status={status}
55-
description={description}
56-
emphasis={emphasis}
57-
/>
58-
{emphasis === 'high' && <TrailingIndicator />}
59-
</StickyHeader>
60-
<Box
61-
width={terminalWidth}
62-
borderStyle="round"
63-
borderColor={borderColor}
64-
borderDimColor={borderDimColor}
65-
borderTop={false}
66-
borderBottom={false}
67-
borderLeft={true}
68-
borderRight={true}
69-
paddingX={1}
70-
flexDirection="column"
71-
>
72-
<ToolResultDisplay
73-
resultDisplay={resultDisplay}
74-
availableTerminalHeight={availableTerminalHeight}
75-
terminalWidth={terminalWidth}
76-
renderOutputAsMarkdown={renderOutputAsMarkdown}
77-
/>
58+
activeShellPtyId,
59+
embeddedShellFocused,
60+
ptyId,
61+
config,
62+
}) => {
63+
const isThisShellFocused =
64+
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
65+
status === ToolCallStatus.Executing &&
66+
ptyId === activeShellPtyId &&
67+
embeddedShellFocused;
68+
69+
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
70+
const [userHasFocused, setUserHasFocused] = useState(false);
71+
const showFocusHint = useInactivityTimer(
72+
!!lastUpdateTime,
73+
lastUpdateTime ? lastUpdateTime.getTime() : 0,
74+
SHELL_FOCUS_HINT_DELAY_MS,
75+
);
76+
77+
useEffect(() => {
78+
if (resultDisplay) {
79+
setLastUpdateTime(new Date());
80+
}
81+
}, [resultDisplay]);
82+
83+
useEffect(() => {
84+
if (isThisShellFocused) {
85+
setUserHasFocused(true);
86+
}
87+
}, [isThisShellFocused]);
88+
89+
const isThisShellFocusable =
90+
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
91+
status === ToolCallStatus.Executing &&
92+
config?.getEnableInteractiveShell();
93+
94+
const shouldShowFocusHint =
95+
isThisShellFocusable && (showFocusHint || userHasFocused);
96+
97+
return (
98+
<Box flexDirection="column" width={terminalWidth}>
99+
<StickyHeader
100+
width={terminalWidth}
101+
isFirst={isFirst}
102+
borderColor={borderColor}
103+
borderDimColor={borderDimColor}
104+
>
105+
<ToolStatusIndicator status={status} name={name} />
106+
<ToolInfo
107+
name={name}
108+
status={status}
109+
description={description}
110+
emphasis={emphasis}
111+
/>
112+
{shouldShowFocusHint && (
113+
<Box marginLeft={1} flexShrink={0}>
114+
<Text color={theme.text.accent}>
115+
{isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'}
116+
</Text>
117+
</Box>
118+
)}
119+
{emphasis === 'high' && <TrailingIndicator />}
120+
</StickyHeader>
121+
<Box
122+
width={terminalWidth}
123+
borderStyle="round"
124+
borderColor={borderColor}
125+
borderDimColor={borderDimColor}
126+
borderTop={false}
127+
borderBottom={false}
128+
borderLeft={true}
129+
borderRight={true}
130+
paddingX={1}
131+
flexDirection="column"
132+
>
133+
<ToolResultDisplay
134+
resultDisplay={resultDisplay}
135+
availableTerminalHeight={availableTerminalHeight}
136+
terminalWidth={terminalWidth}
137+
renderOutputAsMarkdown={renderOutputAsMarkdown}
138+
/>
139+
{isThisShellFocused && config && (
140+
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
141+
<ShellInputPrompt
142+
activeShellPtyId={activeShellPtyId ?? null}
143+
focus={embeddedShellFocused}
144+
/>
145+
</Box>
146+
)}
147+
</Box>
78148
</Box>
79-
</Box>
80-
);
149+
);
150+
};

packages/cli/src/ui/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export const SHELL_NAME = 'Shell';
2424
// usage.
2525
export const MAX_GEMINI_MESSAGE_LINES = 65536;
2626

27+
export const SHELL_FOCUS_HINT_DELAY_MS = 5000;
28+
2729
// Tool status symbols used in ToolMessage component
2830
export const TOOL_STATUS = {
2931
SUCCESS: '✓',

packages/cli/src/ui/hooks/shellCommandProcessor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export const useShellCommandProcessor = (
7676
terminalHeight?: number,
7777
) => {
7878
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
79+
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
80+
7981
const handleShellCommand = useCallback(
8082
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
8183
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
@@ -202,6 +204,7 @@ export const useShellCommandProcessor = (
202204

203205
// Throttle pending UI updates, but allow forced updates.
204206
if (shouldUpdate) {
207+
setLastShellOutputTime(Date.now());
205208
setPendingHistoryItem((prevItem) => {
206209
if (prevItem?.type === 'tool_group') {
207210
return {
@@ -366,5 +369,5 @@ export const useShellCommandProcessor = (
366369
],
367370
);
368371

369-
return { handleShellCommand, activeShellPtyId };
372+
return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
370373
};

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export const useGeminiStream = (
136136
markToolsAsSubmitted,
137137
setToolCallsForDisplay,
138138
cancelAllToolCalls,
139+
lastToolOutputTime,
139140
] = useReactToolScheduler(
140141
async (completedToolCallsFromScheduler) => {
141142
// This onComplete is called when ALL scheduled tools for a given batch are done.
@@ -211,17 +212,18 @@ export const useGeminiStream = (
211212
await done;
212213
setIsResponding(false);
213214
}, []);
214-
const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor(
215-
addItem,
216-
setPendingHistoryItem,
217-
onExec,
218-
onDebugMessage,
219-
config,
220-
geminiClient,
221-
setShellInputFocused,
222-
terminalWidth,
223-
terminalHeight,
224-
);
215+
const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
216+
useShellCommandProcessor(
217+
addItem,
218+
setPendingHistoryItem,
219+
onExec,
220+
onDebugMessage,
221+
config,
222+
geminiClient,
223+
setShellInputFocused,
224+
terminalWidth,
225+
terminalHeight,
226+
);
225227

226228
const activePtyId = activeShellPtyId || activeToolPtyId;
227229

@@ -681,8 +683,9 @@ export const useGeminiStream = (
681683
[FinishReason.UNEXPECTED_TOOL_CALL]:
682684
'Response stopped due to unexpected tool call.',
683685
[FinishReason.IMAGE_PROHIBITED_CONTENT]:
684-
'Response stopped due to prohibited content.',
685-
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.',
686+
'Response stopped due to prohibited image content.',
687+
[FinishReason.NO_IMAGE]:
688+
'Response stopped because no image was generated.',
686689
};
687690

688691
const message = finishReasonMessages[finishReason];
@@ -1348,6 +1351,8 @@ export const useGeminiStream = (
13481351
storage,
13491352
]);
13501353

1354+
const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime);
1355+
13511356
return {
13521357
streamingState,
13531358
submitQuery,
@@ -1359,5 +1364,6 @@ export const useGeminiStream = (
13591364
handleApprovalModeChange,
13601365
activePtyId,
13611366
loopDetectionConfirmationRequest,
1367+
lastOutputTime,
13621368
};
13631369
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useState, useEffect } from 'react';
8+
9+
/**
10+
* Returns true after a specified delay of inactivity.
11+
* Inactivity is defined as 'trigger' not changing for 'delayMs' milliseconds.
12+
*
13+
* @param isActive Whether the timer should be running.
14+
* @param trigger Any value that, when changed, resets the inactivity timer.
15+
* @param delayMs The delay in milliseconds before considering the state inactive.
16+
*/
17+
export const useInactivityTimer = (
18+
isActive: boolean,
19+
trigger: unknown,
20+
delayMs: number = 5000,
21+
): boolean => {
22+
const [isInactive, setIsInactive] = useState(false);
23+
24+
useEffect(() => {
25+
if (!isActive) {
26+
setIsInactive(false);
27+
return;
28+
}
29+
30+
setIsInactive(false);
31+
const timer = setTimeout(() => {
32+
setIsInactive(true);
33+
}, delayMs);
34+
35+
return () => clearTimeout(timer);
36+
}, [isActive, trigger, delayMs]);
37+
38+
return isInactive;
39+
};

0 commit comments

Comments
 (0)