feat: add experimental in-progress steering hints

This commit is contained in:
Dmitry Lyalin
2026-02-10 20:35:57 -05:00
parent ef02cec2cd
commit 50fda21093
29 changed files with 728 additions and 7 deletions
+6
View File
@@ -160,6 +160,8 @@ const baseMockUiState = {
proQuotaRequest: null,
validationRequest: null,
},
hintMode: false,
hintBuffer: '',
};
export const mockAppState: AppState = {
@@ -209,6 +211,10 @@ const mockUIActions: UIActions = {
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
onHintClear: vi.fn(),
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
+92 -1
View File
@@ -94,7 +94,11 @@ import { basename } from 'node:path';
import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import {
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
useGeminiStream,
} from './hooks/useGeminiStream.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
@@ -963,6 +967,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
const consumePendingHints = useCallback(() => {
const userHints = config.consumeUserHints();
if (userHints.length === 0) {
return null;
}
return userHints.join('\n');
}, [config]);
const getUserHint = useCallback(
() => consumePendingHints(),
[consumePendingHints],
);
const {
streamingState,
submitQuery,
@@ -1001,6 +1017,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalWidth,
terminalHeight,
embeddedShellFocused,
getUserHint,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
@@ -1103,10 +1120,38 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
const handleHintSubmit = useCallback(
(hint: string) => {
const trimmed = hint.trim();
if (!trimmed) {
return;
}
config.addUserHint(trimmed);
// Render hints as regular user input so they look like normal commands.
historyManager.addItem({
type: MessageType.USER,
text: trimmed,
});
},
[config, historyManager],
);
const handleFinalSubmit = useCallback(
async (submittedValue: string) => {
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
streamingState === StreamingState.Responding ||
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]);
if (isAgentRunning && !isSlash) {
handleHintSubmit(submittedValue);
addInput(submittedValue);
return;
}
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
@@ -1148,7 +1193,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
handleHintSubmit,
],
);
@@ -1814,6 +1862,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
useEffect(() => {
if (
!isConfigInitialized ||
streamingState !== StreamingState.Idle ||
!isMcpReady
) {
return;
}
const pendingHint = consumePendingHints();
if (!pendingHint) {
return;
}
const geminiClient = config.getGeminiClient();
void generateSteeringAckMessage(geminiClient, pendingHint).then(
(ackText) => {
historyManager.addItem({
type: 'info',
icon: '· ',
color: 'gray',
marginBottom: 1,
text: ackText,
} as Omit<HistoryItem, 'id'>);
},
);
void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]);
}, [
config,
consumePendingHints,
historyManager,
isConfigInitialized,
isMcpReady,
streamingState,
submitQuery,
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
@@ -1975,6 +2060,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
hintMode: false,
hintBuffer: '',
}),
[
isThemeDialogOpen,
@@ -2137,6 +2224,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
onHintClear: () => {},
onHintSubmit: () => {},
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
+2 -1
View File
@@ -71,7 +71,8 @@ export const Footer: React.FC = () => {
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
const showDebugProfiler = debugMode || isDevelopment;
const showDebugProfiler =
debugMode || (isDevelopment && settings.merged.general.devtools);
return (
<Box
@@ -96,6 +96,7 @@ describe('<Header />', () => {
},
background: {
primary: '',
hintMode: '',
diff: { added: '', removed: '' },
},
border: {
@@ -44,6 +44,18 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('Hello');
});
it('renders HintMessage for "hint" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'hint',
text: 'Try using ripgrep first',
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Try using ripgrep first');
});
it('renders UserMessage for "user" type with slash command', () => {
const item: HistoryItem = {
...baseItem,
@@ -35,6 +35,7 @@ import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -71,6 +72,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />
)}
{itemForDisplay.type === 'hint' && (
<HintMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}
@@ -102,6 +106,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
text={itemForDisplay.text}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
/>
)}
{itemForDisplay.type === 'warning' && (
@@ -13,19 +13,21 @@ interface InfoMessageProps {
text: string;
icon?: string;
color?: string;
marginBottom?: number;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
icon,
color,
marginBottom,
}) => {
color ??= theme.status.warning;
const prefix = icon ?? ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginTop={1}>
<Box flexDirection="row" marginTop={1} marginBottom={marginBottom ?? 0}>
<Box width={prefixWidth}>
<Text color={color}>{prefix}</Text>
</Box>
@@ -73,6 +73,10 @@ export interface UIActions {
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
onHintClear: () => void;
onHintSubmit: (hint: string) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
}
@@ -173,6 +173,8 @@ export interface UIState {
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
hintMode: boolean;
hintBuffer: string;
transientMessage: {
text: string;
type: TransientMessageType;
@@ -56,6 +56,11 @@ const MockedGeminiClientClass = vi.hoisted(() =>
this.startChat = mockStartChat;
this.sendMessageStream = mockSendMessageStream;
this.addHistory = vi.fn();
this.generateContent = vi.fn().mockResolvedValue({
candidates: [
{ content: { parts: [{ text: 'Got it. Focusing on tests only.' }] } },
],
});
this.getCurrentSequenceModel = vi.fn().mockReturnValue('test-model');
this.getChat = vi.fn().mockReturnValue({
recordCompletedToolCalls: vi.fn(),
@@ -661,6 +666,113 @@ describe('useGeminiStream', () => {
);
});
it('should inject steering hint prompt for continuation', async () => {
const toolCallResponseParts: Part[] = [{ text: 'tool final response' }];
const completedToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'tool1',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-ack',
},
status: 'success',
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts: toolCallResponseParts,
errorType: undefined,
},
tool: {
displayName: 'MockTool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall,
];
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Applied the requested adjustment.',
};
})(),
);
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
renderHookWithProviders(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
undefined,
() => 'focus on tests only',
),
);
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
const sentParts = mockSendMessageStream.mock.calls[0][0] as Part[];
const injectedHintPart = sentParts[0] as { text?: string };
expect(injectedHintPart.text).toContain(
'User steering update: "focus on tests only"',
);
expect(injectedHintPart.text).toContain(
'Classify it as ADD_TASK, MODIFY_TASK, CANCEL_TASK, or EXTRA_CONTEXT.',
);
expect(injectedHintPart.text).toContain(
'Do not cancel/skip tasks unless the user explicitly cancels them.',
);
expect(
mockAddItem.mock.calls.some(
([item]) =>
item?.type === 'info' &&
typeof item.text === 'string' &&
item.text.includes('Got it. Focusing on tests only.'),
),
).toBe(true);
});
it('should handle all tool calls being cancelled', async () => {
const cancelledToolCalls: TrackedToolCall[] = [
{
+128 -1
View File
@@ -8,6 +8,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
GeminiEventType as ServerGeminiEventType,
getErrorMessage,
getResponseText,
isNodeError,
MessageSenderType,
logUserPrompt,
@@ -47,7 +48,12 @@ import type {
GeminiErrorEventValue,
RetryAttemptPayload,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import {
type Content,
type Part,
type PartListUnion,
FinishReason,
} from '@google/genai';
import type {
HistoryItem,
HistoryItemThinking,
@@ -81,6 +87,7 @@ import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
import { theme } from '../semantic-colors.js';
type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
@@ -98,6 +105,102 @@ enum StreamProcessingStatus {
Error,
}
const USER_STEERING_INSTRUCTION =
'Internal instruction: Re-evaluate the active plan using this user steering update. ' +
'Classify it as ADD_TASK, MODIFY_TASK, CANCEL_TASK, or EXTRA_CONTEXT. ' +
'Apply minimal-diff changes only to affected tasks and keep unaffected tasks active. ' +
'Do not cancel/skip tasks unless the user explicitly cancels them. ' +
'Acknowledge the steering briefly and state the course correction.';
export function buildUserSteeringHintPrompt(hintText: string): string {
const trimmedText = hintText.trim();
return `User steering update: "${trimmedText}"\n${USER_STEERING_INSTRUCTION}`;
}
const STEERING_ACK_INSTRUCTION =
'Write one short, friendly sentence acknowledging a user steering update for an in-progress task. ' +
'Be concrete when possible (e.g., mention skipped/cancelled item numbers). ' +
'Do not apologize, do not mention internal policy, and do not add extra steps.';
const STEERING_ACK_TIMEOUT_MS = 1200;
const STEERING_ACK_MAX_INPUT_CHARS = 320;
const STEERING_ACK_MAX_OUTPUT_CHARS = 90;
const STEERING_ACK_INPUT_TRUNCATION_SUFFIX = '\n...[truncated]';
function buildSteeringFallbackMessage(hintText: string): string {
const normalized = hintText.replace(/\s+/g, ' ').trim();
if (!normalized) {
return 'Understood. Adjusting the plan.';
}
if (normalized.length <= 64) {
return `Understood. ${normalized}`;
}
return `Understood. ${normalized.slice(0, 61)}...`;
}
export async function generateSteeringAckMessage(
geminiClient: GeminiClient,
hintText: string,
): Promise<string> {
const truncateSteeringAckInput = (input: string): string => {
if (input.length <= STEERING_ACK_MAX_INPUT_CHARS) {
return input;
}
if (
STEERING_ACK_MAX_INPUT_CHARS <=
STEERING_ACK_INPUT_TRUNCATION_SUFFIX.length
) {
return input.slice(0, STEERING_ACK_MAX_INPUT_CHARS);
}
return (
input.slice(
0,
STEERING_ACK_MAX_INPUT_CHARS -
STEERING_ACK_INPUT_TRUNCATION_SUFFIX.length,
) + STEERING_ACK_INPUT_TRUNCATION_SUFFIX
);
};
const fallbackText = buildSteeringFallbackMessage(hintText);
const safeHint = truncateSteeringAckInput(
hintText.replace(/\s+/g, ' ').trim(),
);
const contents: Content[] = [
{
role: 'user',
parts: [
{
text: `${STEERING_ACK_INSTRUCTION}\n\nUser input:\n"""${safeHint}"""`,
},
],
},
];
const abortController = new AbortController();
const timeout = setTimeout(
() => abortController.abort(),
STEERING_ACK_TIMEOUT_MS,
);
try {
const response = await geminiClient.generateContent(
{ model: 'flash-lite-helper' },
contents,
abortController.signal,
);
const responseText = getResponseText(response)?.replace(/\s+/g, ' ').trim();
if (!responseText) {
return fallbackText;
}
if (responseText.length > STEERING_ACK_MAX_OUTPUT_CHARS) {
return responseText.slice(0, STEERING_ACK_MAX_OUTPUT_CHARS).trimEnd();
}
return responseText;
} catch {
return fallbackText;
} finally {
clearTimeout(timeout);
}
}
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
@@ -185,6 +288,7 @@ export const useGeminiStream = (
terminalWidth: number,
terminalHeight: number,
isShellFocused?: boolean,
getUserHint?: () => string | null,
) => {
const [initError, setInitError] = useState<string | null>(null);
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
@@ -1561,6 +1665,28 @@ export const useGeminiStream = (
const responsesToSend: Part[] = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
if (getUserHint) {
const userHint = getUserHint();
if (userHint && userHint.trim().length > 0) {
const hintText = userHint.trim();
responsesToSend.unshift({
text: buildUserSteeringHintPrompt(hintText),
});
void generateSteeringAckMessage(geminiClient, hintText).then(
(ackText) => {
addItem({
type: 'info',
icon: '· ',
color: theme.text.secondary,
marginBottom: 1,
text: ackText,
} as Omit<HistoryItem, 'id'>);
},
);
}
}
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
@@ -1593,6 +1719,7 @@ export const useGeminiStream = (
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
getUserHint,
],
);
+1
View File
@@ -36,6 +36,7 @@ const noColorSemanticColors: SemanticColors = {
},
background: {
primary: '',
hintMode: '',
diff: {
added: '',
removed: '',
@@ -16,6 +16,7 @@ export interface SemanticColors {
};
background: {
primary: string;
hintMode: string;
diff: {
added: string;
removed: string;
@@ -48,6 +49,7 @@ export const lightSemanticColors: SemanticColors = {
},
background: {
primary: lightTheme.Background,
hintMode: '#E8E0F0',
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
@@ -80,6 +82,7 @@ export const darkSemanticColors: SemanticColors = {
},
background: {
primary: darkTheme.Background,
hintMode: '#352A45',
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
+2
View File
@@ -131,6 +131,7 @@ export class Theme {
},
background: {
primary: this.colors.Background,
hintMode: this.type === 'light' ? '#E8E0F0' : '#352A45',
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
@@ -400,6 +401,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
background: {
primary: customTheme.background?.primary ?? colors.Background,
hintMode: 'magenta',
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
+7
View File
@@ -123,6 +123,7 @@ export type HistoryItemInfo = HistoryItemBase & {
text: string;
icon?: string;
color?: string;
marginBottom?: number;
};
export type HistoryItemError = HistoryItemBase & {
@@ -225,6 +226,11 @@ export type HistoryItemThinking = HistoryItemBase & {
thought: ThoughtSummary;
};
export type HistoryItemHint = HistoryItemBase & {
type: 'hint';
text: string;
};
export type HistoryItemChatList = HistoryItemBase & {
type: 'chat_list';
chats: ChatDetail[];
@@ -349,6 +355,7 @@ export type HistoryItemWithoutId =
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
| HistoryItemHint
| HistoryItemHooksList;
export type HistoryItem = HistoryItemWithoutId & { id: number };