diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 3b221519523..09d877e8a26 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -42,6 +42,7 @@ export enum Command {
// Text input
SUBMIT = 'submit',
+ QUEUE_MESSAGE = 'queueMessage',
NEWLINE = 'newline',
VOICE_PUSH_TO_TALK = 'voicePushToTalk',
@@ -193,6 +194,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
shift: false,
},
],
+ [Command.QUEUE_MESSAGE]: [
+ { key: 'q', ctrl: true, command: false, shift: false, paste: false },
+ ],
// Split into multiple data-driven bindings
// Now also includes shift+enter for multi-line input
[Command.NEWLINE]: [
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index e7eab689b9c..0bcf66acca9 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -1841,7 +1841,10 @@ export default {
'Press Ctrl+C again to exit.': 'Press Ctrl+C again to exit.',
'Press Ctrl+D again to exit.': 'Press Ctrl+D again to exit.',
'Press Esc again to clear.': 'Press Esc again to clear.',
- 'Press ↑ to edit queued messages': 'Press ↑ to edit queued messages',
+ 'Ctrl+Q to queue · ↑ to edit queued messages':
+ 'Ctrl+Q to queue · ↑ to edit queued messages',
+ 'Enter to steer · Ctrl+Q to queue': 'Enter to steer · Ctrl+Q to queue',
+ 'Queue message for the next turn': 'Queue message for the next turn',
// ============================================================================
// MCP Status
@@ -2228,6 +2231,7 @@ export default {
'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry',
'No failed request to retry.': 'No failed request to retry.',
'to retry last request': 'to retry last request',
+ 'to queue for the next turn': 'to queue for the next turn',
// ============================================================================
// Coding Plan Authentication
diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js
index 16ff8359303..b75d94778a9 100644
--- a/packages/cli/src/i18n/locales/zh-TW.js
+++ b/packages/cli/src/i18n/locales/zh-TW.js
@@ -1613,7 +1613,11 @@ export default {
'Press Ctrl+C again to exit.': '再次按 Ctrl+C 退出',
'Press Ctrl+D again to exit.': '再次按 Ctrl+D 退出',
'Press Esc again to clear.': '再次按 Esc 清除',
- 'Press ↑ to edit queued messages': '按 ↑ 編輯排隊消息',
+ 'Ctrl+Q to queue · ↑ to edit queued messages':
+ 'Ctrl+Q 排到下一輪 · ↑ 編輯排隊消息',
+ 'Enter to steer · Ctrl+Q to queue':
+ 'Enter 追加到目前任務 · Ctrl+Q 排到下一輪',
+ 'Queue message for the next turn': '將消息排到下一輪',
'No MCP servers configured.': '未配置 MCP servers',
'◌ MCP servers are starting up ({{count}} initializing)...':
'◌ MCP servers 正在啟動({{count}} 個正在初始化)...',
@@ -1825,6 +1829,7 @@ export default {
'Press Ctrl+Y to retry': '按 Ctrl+Y 重試。',
'No failed request to retry.': '沒有可重試的失敗請求。',
'to retry last request': '重試上一次請求',
+ 'to queue for the next turn': '排到下一輪',
'API key cannot be empty.': 'API Key 不能為空。',
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
'無效的 API Key,Coding Plan API Key 均以 "sk-sp-" 開頭,請檢查',
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 37b70c3766c..59efbc1b420 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -1763,7 +1763,11 @@ export default {
'Press Ctrl+C again to exit.': '再次按 Ctrl+C 退出',
'Press Ctrl+D again to exit.': '再次按 Ctrl+D 退出',
'Press Esc again to clear.': '再次按 Esc 清除',
- 'Press ↑ to edit queued messages': '按 ↑ 编辑排队消息',
+ 'Ctrl+Q to queue · ↑ to edit queued messages':
+ 'Ctrl+Q 排到下一轮 · ↑ 编辑排队消息',
+ 'Enter to steer · Ctrl+Q to queue':
+ 'Enter 追加到当前任务 · Ctrl+Q 排到下一轮',
+ 'Queue message for the next turn': '将消息排到下一轮',
// ============================================================================
// MCP Status
@@ -2017,6 +2021,7 @@ export default {
'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。',
'No failed request to retry.': '没有可重试的失败请求。',
'to retry last request': '重试上一次请求',
+ 'to queue for the next turn': '排到下一轮',
// ============================================================================
// Coding Plan Authentication
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 5e6ed045e6e..8daf7259289 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -976,6 +976,48 @@ describe('AppContainer State Management', () => {
).toBe(true);
});
+ it('marks Ctrl+Q submissions to wait for the idle boundary', () => {
+ const mockQueueMessage = vi.fn();
+ const mockSubmitQuery = vi.fn();
+
+ mockedUseGeminiStream.mockReturnValue({
+ streamingState: 'responding',
+ submitQuery: mockSubmitQuery,
+ initError: null,
+ pendingHistoryItems: [],
+ thought: null,
+ cancelOngoingRequest: vi.fn(),
+ retryLastPrompt: vi.fn(),
+ streamingResponseLengthRef: { current: 0 },
+ isReceivingContent: false,
+ });
+ mockedUseMessageQueue.mockReturnValue({
+ messageQueue: [],
+ addMessage: mockQueueMessage,
+ clearQueue: vi.fn(),
+ getQueuedMessagesText: vi.fn().mockReturnValue(''),
+ popAllMessages: vi.fn().mockReturnValue(null),
+ drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue(null),
+ });
+
+ render(
+ ,
+ );
+
+ capturedUIActions.handleFinalSubmit('/btw next turn', {
+ deferUntilIdle: true,
+ });
+
+ expect(mockQueueMessage).toHaveBeenCalledWith('/btw next turn', true);
+ expect(mockSubmitQuery).not.toHaveBeenCalled();
+ });
+
it('submits /btw immediately instead of queueing while responding', () => {
const mockSubmitQuery = vi.fn();
const mockQueueMessage = vi.fn();
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 7274b265c65..55325bef516 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1860,6 +1860,7 @@ export const AppContainer = (props: AppContainerProps) => {
const cancelHandlerRef = useRef<(info?: CancelSubmitInfo) => void>(() => {});
const midTurnDrainRef = useRef<(() => string[]) | null>(null);
+ const midTurnRestoreRef = useRef<((messages: string[]) => void) | null>(null);
const {
streamingState,
@@ -1899,6 +1900,7 @@ export const AppContainer = (props: AppContainerProps) => {
logger,
availableTerminalHeightRef,
terminalWidthRef,
+ midTurnRestoreRef,
);
cancelOngoingRequestRef.current = cancelOngoingRequest;
@@ -2010,6 +2012,7 @@ export const AppContainer = (props: AppContainerProps) => {
messageQueue,
addMessage,
popAllMessages,
+ restoreMessages,
drainQueue,
popNextSegment,
} = useMessageQueue();
@@ -2018,6 +2021,7 @@ export const AppContainer = (props: AppContainerProps) => {
// drainQueue reads the synchronous queueRef inside the hook, so it
// stays consistent with popNextSegment even before React re-renders.
midTurnDrainRef.current = drainQueue;
+ midTurnRestoreRef.current = restoreMessages;
// Connect remote input watcher to submitQuery for bidirectional sync.
// When an external process writes a command to the input-file,
@@ -2147,7 +2151,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(
- (submittedValue: string) => {
+ (submittedValue: string, options?: { deferUntilIdle?: boolean }) => {
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
@@ -2195,6 +2199,10 @@ export const AppContainer = (props: AppContainerProps) => {
`\n${buildWorkflowSteeringNotice()}\n\n\n` +
submittedValue;
}
+ if (options?.deferUntilIdle) {
+ addMessage(submittedValue, true);
+ return;
+ }
if (
streamingState === StreamingState.Responding &&
isBtwCommand(submittedValue)
@@ -3969,7 +3977,7 @@ export const AppContainer = (props: AppContainerProps) => {
if (isTranscriptOpenRef.current) return;
// Two-phase: batch plain prompts as one turn, else pop next slash command.
- const plainPrompts = drainQueue();
+ const plainPrompts = drainQueue(true);
const submission =
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
if (submission === null) return;
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 66321a80e3a..394afba0070 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -17,6 +17,7 @@ import { VimModeProvider } from '../contexts/VimModeContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { LoadedSettings } from '../../config/settings.js';
+import { StreamingState } from '../types.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
@@ -149,6 +150,18 @@ describe('', () => {
expect(lastFrame()).not.toContain('workflow active');
});
+ it('shows steer and queue shortcuts while the model is responding', () => {
+ const { lastFrame } = renderWithWidth(
+ 120,
+ createMockUIState({
+ streamingState: StreamingState.Responding,
+ showAutoAcceptIndicator: ApprovalMode.DEFAULT,
+ }),
+ );
+
+ expect(lastFrame()).toContain('Enter to steer · Ctrl+Q to queue');
+ });
+
it('shows deferred IDE connection progress', () => {
const { lastFrame } = renderWithWidth(
120,
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 85f55e54970..b2497443b82 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -25,6 +25,7 @@ import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { GoalPill, useFooterGoalState } from './GoalPill.js';
import { CronPill, useFooterCronTaskCount } from './CronPill.js';
import { t } from '../../i18n/index.js';
+import { StreamingState } from '../types.js';
export const Footer: React.FC = () => {
const uiState = useUIState();
@@ -106,6 +107,10 @@ export const Footer: React.FC = () => {
message: uiState.startupIdeConnectionStatus.message,
})}
+ ) : uiState.streamingState === StreamingState.Responding ? (
+
+ {t('Enter to steer · Ctrl+Q to queue')}
+
) : showAutoAcceptIndicator !== undefined ? (
) : suppressHint ? null : (
diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx
index b7fe57c0699..1c5acc94d64 100644
--- a/packages/cli/src/ui/components/Help.tsx
+++ b/packages/cli/src/ui/components/Help.tsx
@@ -160,6 +160,7 @@ const GeneralHelp: React.FC<{ width: number }> = ({ width }) => {
['Tab', t('Accept ghost text or completion')],
['Esc Esc', t('Clear input or cancel operation')],
['Ctrl+L', t('Clear the screen')],
+ ['Ctrl+Q', t('Queue message for the next turn')],
[
process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J',
t('Insert a newline'),
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index b10b2edc759..f0232864c80 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -410,6 +410,23 @@ describe('InputPrompt', () => {
unmount();
});
+ it('queues the prompt for the next turn on Ctrl+Q', async () => {
+ props.buffer.setText('send this later');
+ const { stdin, unmount } = renderWithProviders();
+
+ act(() => {
+ stdin.write('\x11');
+ });
+
+ await waitFor(() => {
+ expect(props.onSubmit).toHaveBeenCalledWith('send this later', {
+ deferUntilIdle: true,
+ });
+ });
+ expect(props.buffer.setText).toHaveBeenCalledWith('');
+ unmount();
+ });
+
it('expands large paste placeholders before stashing', () => {
const pending = new Map([
['[Pasted Content 1200 chars]', 'full pasted content'],
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 6279e96dc9b..90743675266 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -165,7 +165,7 @@ export function expandPendingPastePlaceholders(
export interface InputPromptProps {
buffer: TextBuffer;
- onSubmit: (value: string) => void;
+ onSubmit: (value: string, options?: { deferUntilIdle?: boolean }) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
@@ -585,7 +585,7 @@ export const InputPrompt: React.FC = ({
const resetHistoryNavRef = useRef<() => void>(() => {});
const handleSubmitAndClear = useCallback(
- (submittedValue: string) => {
+ (submittedValue: string, deferUntilIdle = false) => {
exportCompletion.reset();
// Expand any large paste placeholders to their full content before submitting
let finalValue = submittedValue;
@@ -610,7 +610,11 @@ export const InputPrompt: React.FC = ({
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
clearPromptStash(targetDir);
- onSubmit(finalValue);
+ if (deferUntilIdle) {
+ onSubmit(finalValue, { deferUntilIdle: true });
+ } else {
+ onSubmit(finalValue);
+ }
// Reset history navigation so the next Up-arrow starts from the newest
// entry rather than advancing from whatever index the user picked.
@@ -1626,6 +1630,13 @@ export const InputPrompt: React.FC = ({
}
}
+ if (keyMatchers[Command.QUEUE_MESSAGE](key)) {
+ if (buffer.text.trim()) {
+ handleSubmitAndClear(buffer.text, true);
+ }
+ return true;
+ }
+
if (keyMatchers[Command.SUBMIT](key)) {
// When buffer is empty and a suggestion is available, Enter fills the
// buffer instead of submitting — matching Tab/Right-arrow behavior.
diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx
index 646f656aa9f..628d9f0cd7e 100644
--- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx
+++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx
@@ -41,6 +41,7 @@ const getShortcuts = (): Shortcut[] => [
{ key: 'ctrl+o', description: t('to view transcript') },
{ key: 'ctrl+r', description: t('to search history') },
{ key: 'ctrl+y', description: t('to retry last request') },
+ { key: 'ctrl+q', description: t('to queue for the next turn') },
{ key: getPasteKey(), description: t('to paste images') },
{ key: getExternalEditorKey(), description: t('for external editor') },
];
@@ -56,11 +57,11 @@ const COLUMN_GAP = 4;
const MARGIN_LEFT = 2;
const MARGIN_RIGHT = 2;
-// Column distribution for different layouts (5+4+4 for 3 cols, 7+6 for 2 cols)
+// Column distribution for different layouts (5+5+4 for 3 cols, 7+7 for 2 cols)
const COLUMN_SPLITS: Record = {
- 3: [5, 4, 4],
- 2: [7, 6],
- 1: [13],
+ 3: [5, 5, 4],
+ 2: [7, 7],
+ 1: [14],
};
export const KeyboardShortcuts: React.FC = () => {
diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
index eb578ce5b57..dc1b6483fcb 100644
--- a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
+++ b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
@@ -80,6 +80,7 @@ describe('QueuedMessageDisplay', () => {
);
const output = lastFrame();
+ expect(output).toContain('Ctrl+Q to queue');
expect(output).toContain('to edit queued messages');
});
diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx
index d1c63f5b53d..85c1a7a2a30 100644
--- a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx
+++ b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx
@@ -63,7 +63,7 @@ export const QueuedMessageDisplay = ({
{showHint && (
- {t('Press ↑ to edit queued messages')}
+ {t('Ctrl+Q to queue · ↑ to edit queued messages')}
)}
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index c5274c0ec73..d4cfb202136 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -74,7 +74,10 @@ export interface UIActions {
onEscapePromptChange: (show: boolean) => void;
onTabConsumerChange: (active: boolean) => void;
refreshStatic: () => void;
- handleFinalSubmit: (value: string) => void;
+ handleFinalSubmit: (
+ value: string,
+ options?: { deferUntilIdle?: boolean },
+ ) => void;
handleRetryLastPrompt: () => void;
handleClearScreen: () => void;
popAllQueuedMessages: () => string | null;
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 10a88d5eb1a..a4ac19c102f 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -23,6 +23,7 @@ import type {
EditorType,
GeminiClient,
AnyToolInvocation,
+ SteerInput,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
@@ -1070,7 +1071,7 @@ describe('useGeminiStream', () => {
);
});
- it('records mid-turn queued user messages before submitting tool results', async () => {
+ it('records mid-turn queued user messages after tool results accept them', async () => {
const queuedPrompt = 'save the logs locally first';
const recordMidTurnUserMessage = vi.fn();
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
@@ -1110,7 +1111,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1157,9 +1161,7 @@ describe('useGeminiStream', () => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
- const expectedMidTurnMessage = {
- text: `\n[User message received during tool execution]: ${queuedPrompt}`,
- };
+ const expectedMidTurnMessage = { text: queuedPrompt };
expect(recordMidTurnUserMessage).toHaveBeenCalledWith(
[expectedMidTurnMessage],
queuedPrompt,
@@ -1172,8 +1174,8 @@ describe('useGeminiStream', () => {
expect(recordMidTurnUserMessage.mock.invocationCallOrder[0]).toBeLessThan(
mockAddItem.mock.invocationCallOrder[queuedPromptAddItemIndex],
);
- expect(recordMidTurnUserMessage.mock.invocationCallOrder[0]).toBeLessThan(
- mockSendMessageStream.mock.invocationCallOrder[0],
+ expect(mockSendMessageStream.mock.invocationCallOrder[0]).toBeLessThan(
+ recordMidTurnUserMessage.mock.invocationCallOrder[0],
);
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.NOTIFICATION, text: queuedPrompt },
@@ -1183,7 +1185,149 @@ describe('useGeminiStream', () => {
[...toolCallResponseParts, expectedMidTurnMessage],
expect.any(AbortSignal),
'prompt-id-midturn',
- { type: SendMessageType.ToolResult },
+ expect.objectContaining({
+ type: SendMessageType.ToolResult,
+ steerInput: expect.objectContaining({
+ parts: [expectedMidTurnMessage],
+ accept: expect.any(Function),
+ restore: expect.any(Function),
+ }),
+ }),
+ );
+ });
+
+ it('provides queued steer input to core at the next sampling boundary', async () => {
+ const steeredPrompt = 'focus on the error handling';
+ const recordMidTurnUserMessage = vi.fn();
+ mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
+ recordMidTurnUserMessage,
+ });
+ mockSendMessageStream.mockImplementation(() => (async function* () {})());
+ const drainSteer = vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([steeredPrompt])
+ .mockReturnValue([]);
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockConfig,
+ true,
+ mockLoadedSettings,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ 80,
+ 24,
+ { current: drainSteer },
+ ),
+ );
+
+ await act(async () => {
+ await result.current.submitQuery(
+ 'start the analysis',
+ SendMessageType.UserQuery,
+ 'prompt-id-steer',
+ );
+ });
+
+ expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
+ const sendOptions = mockSendMessageStream.mock.calls[0][3] as {
+ getSteerInput?: (signal: AbortSignal) => Promise;
+ };
+ expect(sendOptions.getSteerInput).toEqual(expect.any(Function));
+ let steerInput: SteerInput | undefined;
+ await act(async () => {
+ steerInput = await sendOptions.getSteerInput!(
+ new AbortController().signal,
+ );
+ });
+ expect(steerInput?.parts).toEqual([{ text: steeredPrompt }]);
+ expect(recordMidTurnUserMessage).not.toHaveBeenCalled();
+ expect(mockAddItem).not.toHaveBeenCalledWith(
+ { type: MessageType.NOTIFICATION, text: steeredPrompt },
+ expect.any(Number),
+ );
+ steerInput?.accept();
+ expect(recordMidTurnUserMessage).toHaveBeenCalledWith(
+ [{ text: steeredPrompt }],
+ steeredPrompt,
+ );
+ expect(mockAddItem).toHaveBeenCalledWith(
+ { type: MessageType.NOTIFICATION, text: steeredPrompt },
+ expect.any(Number),
+ );
+ });
+
+ it('restores drained steer input when attachment resolution is cancelled', async () => {
+ const steeredPrompt = 'inspect @/tmp/slow.png';
+ const restoreSteer = vi.fn();
+ vi.spyOn(atCommandProcessor, 'resolveAtCommandQuery').mockImplementation(
+ () => new Promise(() => {}),
+ );
+ const drainSteer = vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([steeredPrompt])
+ .mockReturnValue([]);
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockConfig,
+ true,
+ mockLoadedSettings,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ 80,
+ 24,
+ { current: drainSteer },
+ undefined,
+ undefined,
+ undefined,
+ { current: restoreSteer },
+ ),
+ );
+
+ await act(async () => {
+ await result.current.submitQuery('start the analysis');
+ });
+ const sendOptions = mockSendMessageStream.mock.calls[0][3] as {
+ getSteerInput?: (signal: AbortSignal) => Promise;
+ };
+ const abort = new AbortController();
+ let steerInput: SteerInput | undefined;
+ await act(async () => {
+ const pending = sendOptions.getSteerInput!(abort.signal);
+ abort.abort();
+ steerInput = await pending;
+ });
+
+ expect(steerInput).toBeUndefined();
+ expect(restoreSteer).toHaveBeenCalledWith([steeredPrompt]);
+ expect(mockAddItem).not.toHaveBeenCalledWith(
+ { type: MessageType.NOTIFICATION, text: steeredPrompt },
+ expect.any(Number),
);
});
@@ -1263,7 +1407,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1310,11 +1457,7 @@ describe('useGeminiStream', () => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
- const expectedMidTurnParts: Part[] = [
- {
- text: `\n[User message received during tool execution]: ${transcriptPart.text}`,
- },
- ];
+ const expectedMidTurnParts: Part[] = [transcriptPart];
expect(mockRunVisionBridge).toHaveBeenCalledWith({
config: mockConfig,
parts: [resolvedTextPart, resolvedImagePart],
@@ -1361,7 +1504,7 @@ describe('useGeminiStream', () => {
[...toolCallResponseParts, ...expectedMidTurnParts],
expect.any(AbortSignal),
'prompt-id-midturn-image',
- { type: SendMessageType.ToolResult },
+ expect.objectContaining({ type: SendMessageType.ToolResult }),
);
});
@@ -1436,7 +1579,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1484,11 +1630,7 @@ describe('useGeminiStream', () => {
expect(sent).toContain('inspect @/tmp/screenshot.png and summarize');
expect(sent).not.toContain('inlineData');
expect(recordMidTurnUserMessage).toHaveBeenCalledWith(
- [
- {
- text: `\n[User message received during tool execution]: ${resolvedTextPart.text}`,
- },
- ],
+ [resolvedTextPart],
queuedPrompt,
);
});
@@ -1557,7 +1699,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1630,7 +1775,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-midturn-at-error',
- { type: SendMessageType.ToolResult },
+ expect.objectContaining({ type: SendMessageType.ToolResult }),
);
});
@@ -1677,7 +1822,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1736,7 +1884,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-midturn-at-throw',
- { type: SendMessageType.ToolResult },
+ expect.objectContaining({ type: SendMessageType.ToolResult }),
);
});
@@ -1802,7 +1950,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -1912,7 +2063,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -2019,7 +2173,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -2123,7 +2280,10 @@ describe('useGeminiStream', () => {
} as TrackedCompletedToolCall,
];
const midTurnDrainRef = {
- current: vi.fn().mockReturnValue([queuedPrompt]),
+ current: vi
+ .fn<() => string[]>()
+ .mockReturnValueOnce([queuedPrompt])
+ .mockReturnValue([]),
};
let capturedOnComplete:
@@ -2175,15 +2335,10 @@ describe('useGeminiStream', () => {
expect.any(Number),
);
expect(mockSendMessageStream).toHaveBeenCalledWith(
- [
- ...toolCallResponseParts,
- {
- text: `\n[User message received during tool execution]: ${queuedPrompt}`,
- },
- ],
+ [...toolCallResponseParts, { text: queuedPrompt }],
expect.any(AbortSignal),
'prompt-id-midturn',
- { type: SendMessageType.ToolResult },
+ expect.objectContaining({ type: SendMessageType.ToolResult }),
);
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index ad57018c383..bfaac9bb6ba 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -27,6 +27,7 @@ import {
type GeminiErrorEventValue,
type StopFailureErrorType,
type ActiveGoal,
+ type SteerInput,
GeminiEventType as ServerGeminiEventType,
SendMessageType,
createDebugLogger,
@@ -89,7 +90,7 @@ import {
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { fitPendingSlice } from '../utils/pending-rendered-height.js';
import { useStateAndRef } from './useStateAndRef.js';
-import { prefixMidTurnUserMessageParts } from '../../utils/midTurnUserMessage.js';
+import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
import { isInlineModelOverrideAllowed } from '../../utils/acpModelUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
@@ -151,6 +152,11 @@ interface PendingDuplicateToolResponses {
responseParts: Part[];
}
+interface ResolvedSteerMessages {
+ parts: Part[];
+ accept: () => void;
+}
+
/**
* Pull the assistant's most recent visible text from the UI history. Used as
* an intent prefix for tool-use summary generation so the summarizer knows
@@ -444,6 +450,7 @@ export const useGeminiStream = (
// Live terminal width, paired with the height ref so the commit loop reads
// both dimensions consistently across a mid-stream resize.
terminalWidthRef?: React.RefObject,
+ midTurnRestoreRef?: React.RefObject<((messages: string[]) => void) | null>,
) => {
const [initError, setInitError] = useState(null);
const abortControllerRef = useRef(null);
@@ -2288,6 +2295,201 @@ export const useGeminiStream = (
],
);
+ const resolveSteeredMessages = useCallback(
+ async (
+ messages: string[],
+ signal: AbortSignal,
+ ): Promise => {
+ const resolvedMessages: Part[] = [];
+ const resolvedForRecording: Array<{
+ message: string;
+ parts: Part[];
+ sideEffects: Array<() => void>;
+ }> = [];
+ const timestamp = Date.now();
+
+ for (let index = 0; index < messages.length; index += 1) {
+ if (signal.aborted) break;
+
+ const message = messages[index];
+ const sideEffects: Array<() => void> = [];
+ let resolvedQuery: PartListUnion = [{ text: message }];
+ if (isAtCommand(message)) {
+ const timeout = new AbortController();
+ const atCommandSignal = AbortSignal.any([signal, timeout.signal]);
+ const timeoutId = setTimeout(() => {
+ timeout.abort(
+ new Error(MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE),
+ );
+ }, MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS);
+ try {
+ const atCommandResult = await resolveWithAbort(
+ atCommandSignal,
+ () =>
+ resolveAtCommandQuery({
+ query: message,
+ config,
+ onDebugMessage,
+ messageId: timestamp + index,
+ signal: atCommandSignal,
+ }),
+ );
+ const shouldSkipMessage =
+ !atCommandResult.shouldProceed &&
+ (atCommandResult.toolDisplays?.length ?? 0) > 0;
+ if (
+ atCommandResult.shouldProceed &&
+ atCommandResult.processedQuery !== null
+ ) {
+ resolvedQuery = atCommandResult.processedQuery;
+ } else if (atCommandResult.toolDisplays?.length) {
+ const toolDisplays = atCommandResult.toolDisplays;
+ const showToolDisplays = () =>
+ addItem(
+ { type: 'tool_group', tools: toolDisplays },
+ timestamp + index,
+ );
+ if (shouldSkipMessage) showToolDisplays();
+ else sideEffects.push(showToolDisplays);
+ }
+ if (atCommandResult.recording) {
+ const recordAtCommand = () =>
+ config.getChatRecordingService?.()?.recordAtCommand?.({
+ filesRead: atCommandResult.recording!.filesRead,
+ status: atCommandResult.recording!.status,
+ ...(atCommandResult.recording!.message
+ ? { message: atCommandResult.recording!.message }
+ : {}),
+ userText: message,
+ });
+ if (shouldSkipMessage) recordAtCommand();
+ else sideEffects.push(recordAtCommand);
+ }
+ if (shouldSkipMessage) continue;
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ onDebugMessage(
+ `Failed to resolve mid-turn @ command: ${errorMessage}`,
+ );
+ if (!signal.aborted) {
+ addItem(
+ {
+ type: MessageType.WARNING,
+ text: `Could not attach file: ${errorMessage}`,
+ },
+ Date.now(),
+ );
+ }
+ continue;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ if (signal.aborted) break;
+ }
+
+ const bridgeResult = await applyVisionBridgeIfNeeded(
+ resolvedQuery,
+ timestamp + index,
+ signal,
+ );
+ if (!bridgeResult.shouldProceed) {
+ if (signal.aborted) break;
+ continue;
+ }
+
+ const messageParts = normalizePartList(
+ bridgeResult.parts ?? resolvedQuery,
+ );
+ const formatCheck = checkImageFormatsSupport(messageParts);
+ if (formatCheck.hasUnsupportedFormats) {
+ sideEffects.push(() =>
+ addItem(
+ {
+ type: MessageType.INFO,
+ text: getUnsupportedImageFormatWarning(),
+ },
+ Date.now(),
+ ),
+ );
+ }
+
+ if (resolvedMessages.length > 0 && messageParts.length > 0) {
+ resolvedMessages.push({ text: '\n\n' });
+ }
+ resolvedMessages.push(...messageParts);
+ resolvedForRecording.push({
+ message,
+ parts: messageParts,
+ sideEffects,
+ });
+ }
+
+ if (signal.aborted) return undefined;
+ return {
+ parts: resolvedMessages,
+ accept: () => {
+ for (const { message, parts, sideEffects } of resolvedForRecording) {
+ for (const sideEffect of sideEffects) sideEffect();
+ config
+ .getChatRecordingService?.()
+ ?.recordMidTurnUserMessage(parts, message);
+ addItem(
+ { type: MessageType.NOTIFICATION, text: message },
+ Date.now(),
+ );
+ }
+ },
+ };
+ },
+ [addItem, applyVisionBridgeIfNeeded, config, onDebugMessage],
+ );
+
+ const resolveDrainedSteerMessages = useCallback(
+ async (
+ messages: string[],
+ signal: AbortSignal,
+ ): Promise => {
+ try {
+ const resolved = await resolveSteeredMessages(messages, signal);
+ if (signal.aborted) {
+ midTurnRestoreRef?.current?.(messages);
+ return undefined;
+ }
+ if (!resolved || resolved.parts.length === 0) return undefined;
+ let settled = false;
+ return {
+ parts: resolved.parts,
+ accept: () => {
+ if (settled) return;
+ settled = true;
+ resolved.accept();
+ },
+ restore: () => {
+ if (settled) return;
+ settled = true;
+ midTurnRestoreRef?.current?.(messages);
+ },
+ };
+ } catch (error) {
+ midTurnRestoreRef?.current?.(messages);
+ onDebugMessage(
+ `Failed to prepare steer input: ${getErrorMessage(error)}`,
+ );
+ return undefined;
+ }
+ },
+ [midTurnRestoreRef, onDebugMessage, resolveSteeredMessages],
+ );
+
+ const drainSteerAtBoundary = useCallback(
+ async (signal: AbortSignal): Promise => {
+ const messages = midTurnDrainRef?.current?.() ?? [];
+ if (messages.length === 0) return undefined;
+ return resolveDrainedSteerMessages(messages, signal);
+ },
+ [midTurnDrainRef, resolveDrainedSteerMessages],
+ );
+
const submitQuery = useCallback(
async (
query: PartListUnion,
@@ -2297,6 +2499,7 @@ export const useGeminiStream = (
notificationDisplayText?: string;
onDelivered?: () => void;
onDeliveryFailed?: () => void;
+ steerInput?: SteerInput;
},
) => {
const allowConcurrentBtwDuringResponse =
@@ -2304,12 +2507,15 @@ export const useGeminiStream = (
streamingState === StreamingState.Responding &&
typeof query === 'string' &&
isBtwCommand(query);
+ const isTurnContinuation =
+ submitType === SendMessageType.ToolResult ||
+ submitType === SendMessageType.Steer;
// Prevent concurrent executions of submitQuery, but allow continuations
// which are part of the same logical flow (tool responses)
if (
isSubmittingQueryRef.current &&
- submitType !== SendMessageType.ToolResult &&
+ !isTurnContinuation &&
!allowConcurrentBtwDuringResponse
) {
metadata?.onDeliveryFailed?.();
@@ -2319,7 +2525,7 @@ export const useGeminiStream = (
if (
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
- submitType !== SendMessageType.ToolResult &&
+ !isTurnContinuation &&
!allowConcurrentBtwDuringResponse
) {
metadata?.onDeliveryFailed?.();
@@ -2349,10 +2555,7 @@ export const useGeminiStream = (
// ToolResult continuations and same-turn btw concurrencies keep
// the trackers untouched — they're piggybacking on an in-flight
// turn that already owns its own snapshot.
- if (
- submitType !== SendMessageType.ToolResult &&
- !allowConcurrentBtwDuringResponse
- ) {
+ if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
lastTurnUserItemRef.current = null;
turnSawContentEventRef.current = false;
handledProviderToolCallIdsRef.current.clear();
@@ -2364,10 +2567,7 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
// Reset quota error flag when starting a new query (not a continuation)
- if (
- submitType !== SendMessageType.ToolResult &&
- !allowConcurrentBtwDuringResponse
- ) {
+ if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
setModelSwitchedFromQuotaError(false);
// Clear model override for new user turns. On retry, preserve a
// skill-selected override so the same model is used again, but drop an
@@ -2504,7 +2704,7 @@ export const useGeminiStream = (
setIsReceivingContent(false);
// Reset char counter only on new user queries; tool-result continuations
// keep accumulating so the token count only goes up within a turn.
- if (submitType !== SendMessageType.ToolResult) {
+ if (!isTurnContinuation) {
streamingResponseLengthRef.current = 0;
}
@@ -2533,6 +2733,10 @@ export const useGeminiStream = (
type: submitType,
notificationDisplayText: metadata?.notificationDisplayText,
modelOverride: modelOverrideRef.current,
+ steerInput: metadata?.steerInput,
+ ...(!allowConcurrentBtwDuringResponse && midTurnDrainRef
+ ? { getSteerInput: drainSteerAtBoundary }
+ : {}),
},
);
@@ -2571,7 +2775,8 @@ export const useGeminiStream = (
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
- if (loopDetectedRef.current) {
+ const loopDetected = loopDetectedRef.current;
+ if (loopDetected) {
loopDetectedRef.current = false;
handleLoopDetectedEvent();
}
@@ -2664,6 +2869,8 @@ export const useGeminiStream = (
setPendingRetryErrorItem,
setPendingThoughtItem,
dualOutput,
+ drainSteerAtBoundary,
+ midTurnDrainRef,
],
);
@@ -3129,15 +3336,15 @@ export const useGeminiStream = (
return;
}
- // Mid-turn queue drain: inject queued user messages alongside tool
- // results so the model sees them in the next API call.
+ // Drain steerable user messages at this sampling boundary and append
+ // them after the tool responses as genuine user content.
// Skip if the turn was cancelled — messages stay in queue for next turn.
const drained =
turnCancelledRef.current || abortControllerRef.current?.signal.aborted
? []
: (midTurnDrainRef?.current?.() ?? []);
+ let drainedSteer: SteerInput | undefined;
if (drained.length > 0) {
- const midTurnTimestamp = Date.now();
const midTurnAbort =
abortControllerRef.current ?? new AbortController();
const shouldTrackMidTurnAbort = !abortControllerRef.current;
@@ -3145,119 +3352,12 @@ export const useGeminiStream = (
auxiliaryAbortRefsRef.current.add(midTurnAbort);
}
try {
- for (let index = 0; index < drained.length; index += 1) {
- if (midTurnAbort.signal.aborted) {
- break;
- }
- const msg = drained[index];
- let resolvedMidTurnQuery: PartListUnion = [{ text: msg }];
- if (isAtCommand(msg)) {
- const atCommandTimeout = new AbortController();
- const atCommandSignal = AbortSignal.any([
- midTurnAbort.signal,
- atCommandTimeout.signal,
- ]);
- const atCommandTimeoutId = setTimeout(() => {
- atCommandTimeout.abort(
- new Error(MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE),
- );
- }, MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS);
- try {
- const atCommandResult = await resolveWithAbort(
- atCommandSignal,
- () =>
- resolveAtCommandQuery({
- query: msg,
- config,
- onDebugMessage,
- messageId: midTurnTimestamp + index,
- signal: atCommandSignal,
- }),
- );
- const shouldSkipMidTurnMessage =
- !atCommandResult.shouldProceed &&
- (atCommandResult.toolDisplays?.length ?? 0) > 0;
- if (
- atCommandResult.shouldProceed &&
- atCommandResult.processedQuery !== null
- ) {
- resolvedMidTurnQuery = atCommandResult.processedQuery;
- } else if (atCommandResult.toolDisplays?.length) {
- addItem(
- { type: 'tool_group', tools: atCommandResult.toolDisplays },
- midTurnTimestamp + index,
- );
- }
- if (atCommandResult.recording) {
- config.getChatRecordingService?.()?.recordAtCommand?.({
- filesRead: atCommandResult.recording.filesRead,
- status: atCommandResult.recording.status,
- ...(atCommandResult.recording.message
- ? { message: atCommandResult.recording.message }
- : {}),
- userText: msg,
- });
- }
- if (shouldSkipMidTurnMessage) {
- continue;
- }
- } catch (error) {
- const errorMessage = getErrorMessage(error);
- onDebugMessage(
- `Failed to resolve mid-turn @ command: ${errorMessage}`,
- );
- if (!midTurnAbort.signal.aborted) {
- addItem(
- {
- type: MessageType.WARNING,
- text: `Could not attach file: ${errorMessage}`,
- },
- Date.now(),
- );
- }
- continue;
- } finally {
- clearTimeout(atCommandTimeoutId);
- }
- if (midTurnAbort.signal.aborted) {
- break;
- }
- }
-
- const bridgeResult = await applyVisionBridgeIfNeeded(
- resolvedMidTurnQuery,
- midTurnTimestamp + index,
- midTurnAbort.signal,
- );
- if (!bridgeResult.shouldProceed) {
- if (midTurnAbort.signal.aborted) {
- break;
- }
- continue;
- }
- resolvedMidTurnQuery = bridgeResult.parts ?? resolvedMidTurnQuery;
-
- const midTurnUserMessageParts = prefixMidTurnUserMessageParts(
- resolvedMidTurnQuery,
- msg,
- );
- const formatCheck = checkImageFormatsSupport(
- midTurnUserMessageParts,
- );
- if (formatCheck.hasUnsupportedFormats) {
- addItem(
- {
- type: MessageType.INFO,
- text: getUnsupportedImageFormatWarning(),
- },
- Date.now(),
- );
- }
- responsesToSend.push(...midTurnUserMessageParts);
- config
- .getChatRecordingService?.()
- ?.recordMidTurnUserMessage(midTurnUserMessageParts, msg);
- addItem({ type: MessageType.NOTIFICATION, text: msg }, Date.now());
+ drainedSteer = await resolveDrainedSteerMessages(
+ drained,
+ midTurnAbort.signal,
+ );
+ if (drainedSteer) {
+ responsesToSend.push(...drainedSteer.parts);
}
} finally {
if (shouldTrackMidTurnAbort) {
@@ -3271,10 +3371,15 @@ export const useGeminiStream = (
turnCancelledRef.current ||
abortControllerRef.current?.signal.aborted
) {
+ drainedSteer?.restore();
return;
}
- submitQuery(responsesToSend, SendMessageType.ToolResult, promptId);
+ void submitQuery(responsesToSend, SendMessageType.ToolResult, promptId, {
+ steerInput: drainedSteer,
+ onDelivered: drainedSteer?.accept,
+ onDeliveryFailed: drainedSteer?.restore,
+ });
},
[
submitQuery,
@@ -3286,8 +3391,7 @@ export const useGeminiStream = (
midTurnDrainRef,
addItem,
dualOutput,
- onDebugMessage,
- applyVisionBridgeIfNeeded,
+ resolveDrainedSteerMessages,
],
);
diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts
index 6d783d0d81e..05ff3f0ba5b 100644
--- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts
+++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts
@@ -218,6 +218,54 @@ describe('useMessageQueue', () => {
expect(drained).toEqual(['a', 'b', 'c']);
expect(result.current.messageQueue).toEqual([]);
});
+
+ it('leaves Ctrl+Q messages queued during an active turn', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('steer now');
+ result.current.addMessage('wait for idle', true);
+ });
+
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue();
+ });
+
+ expect(drained).toEqual(['steer now']);
+ expect(result.current.messageQueue).toEqual(['wait for idle']);
+ });
+
+ it('drains Ctrl+Q messages at the idle boundary', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('wait for idle', true);
+ });
+
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue(true);
+ });
+
+ expect(drained).toEqual(['wait for idle']);
+ expect(result.current.messageQueue).toEqual([]);
+ });
+
+ it('restores interrupted steer messages ahead of newer queued input', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('steer now');
+ });
+ act(() => {
+ result.current.drainQueue();
+ result.current.addMessage('newer input');
+ result.current.restoreMessages(['steer now']);
+ });
+
+ expect(result.current.messageQueue).toEqual(['steer now', 'newer input']);
+ });
});
describe('popNextSegment', () => {
diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts
index 348bfa07008..448d49e7f95 100644
--- a/packages/cli/src/ui/hooks/useMessageQueue.ts
+++ b/packages/cli/src/ui/hooks/useMessageQueue.ts
@@ -9,57 +9,84 @@ import { isSlashCommand } from '../utils/commandUtils.js';
export interface UseMessageQueueReturn {
messageQueue: string[];
- addMessage: (message: string) => void;
+ addMessage: (message: string, deferUntilIdle?: boolean) => void;
clearQueue: () => void;
getQueuedMessagesText: () => string;
/** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
popAllMessages: () => string | null;
- /** Drain plain-text prompts; leave slash commands queued. Safe from non-React callbacks. */
- drainQueue: () => string[];
+ /** Restore interrupted steer messages to the front of the queue. */
+ restoreMessages: (messages: string[]) => void;
+ /**
+ * Drain plain-text prompts that can steer the active turn. Pass true at the
+ * idle boundary to also drain messages explicitly deferred with Ctrl+Q.
+ * Slash commands always stay queued for individual processing.
+ */
+ drainQueue: (includeDeferred?: boolean) => string[];
/** Pop the first item from the queue. */
popNextSegment: () => string | null;
}
+interface QueuedMessage {
+ text: string;
+ deferUntilIdle: boolean;
+}
+
export function useMessageQueue(): UseMessageQueueReturn {
- const [messageQueue, setMessageQueue] = useState([]);
+ const [queuedMessages, setQueuedMessages] = useState([]);
// Synchronous mirror so non-React callbacks see the latest queue.
- const queueRef = useRef([]);
+ const queueRef = useRef([]);
- const addMessage = useCallback((message: string) => {
+ const addMessage = useCallback((message: string, deferUntilIdle = false) => {
const trimmedMessage = message.trim();
if (trimmedMessage.length > 0) {
- queueRef.current = [...queueRef.current, trimmedMessage];
- setMessageQueue(queueRef.current);
+ queueRef.current = [
+ ...queueRef.current,
+ { text: trimmedMessage, deferUntilIdle },
+ ];
+ setQueuedMessages(queueRef.current);
}
}, []);
const clearQueue = useCallback(() => {
queueRef.current = [];
- setMessageQueue([]);
+ setQueuedMessages([]);
}, []);
const getQueuedMessagesText = useCallback(() => {
- if (messageQueue.length === 0) return '';
- return messageQueue.join('\n\n');
- }, [messageQueue]);
+ if (queuedMessages.length === 0) return '';
+ return queuedMessages.map(({ text }) => text).join('\n\n');
+ }, [queuedMessages]);
const popAllMessages = useCallback((): string | null => {
const current = queueRef.current;
if (current.length === 0) return null;
queueRef.current = [];
- setMessageQueue([]);
- return current.join('\n\n');
+ setQueuedMessages([]);
+ return current.map(({ text }) => text).join('\n\n');
+ }, []);
+
+ const restoreMessages = useCallback((messages: string[]) => {
+ const restored = messages
+ .map((text) => text.trim())
+ .filter(Boolean)
+ .map((text) => ({ text, deferUntilIdle: false }));
+ if (restored.length === 0) return;
+ queueRef.current = [...restored, ...queueRef.current];
+ setQueuedMessages(queueRef.current);
}, []);
- const drainQueue = useCallback((): string[] => {
+ const drainQueue = useCallback((includeDeferred = false): string[] => {
const current = queueRef.current;
if (current.length === 0) return [];
- const drained = current.filter((message) => !isSlashCommand(message));
+ const shouldDrain = (message: QueuedMessage) =>
+ !isSlashCommand(message.text) &&
+ (includeDeferred || !message.deferUntilIdle);
+ const drained = current.filter(shouldDrain);
if (drained.length === 0) return [];
- const rest = current.filter((message) => isSlashCommand(message));
+ const rest = current.filter((message) => !shouldDrain(message));
queueRef.current = rest;
- setMessageQueue(rest);
- return drained;
+ setQueuedMessages(rest);
+ return drained.map(({ text }) => text);
}, []);
const popNextSegment = useCallback((): string | null => {
@@ -67,16 +94,17 @@ export function useMessageQueue(): UseMessageQueueReturn {
if (current.length === 0) return null;
const [head, ...rest] = current;
queueRef.current = rest;
- setMessageQueue(rest);
- return head;
+ setQueuedMessages(rest);
+ return head.text;
}, []);
return {
- messageQueue,
+ messageQueue: queuedMessages.map(({ text }) => text),
addMessage,
clearQueue,
getQueuedMessagesText,
popAllMessages,
+ restoreMessages,
drainQueue,
popNextSegment,
};
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index 51fc7a6462c..b7266c15f09 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -48,6 +48,8 @@ describe('keyMatchers', () => {
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
[Command.SUBMIT]: (key: Key) =>
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
+ [Command.QUEUE_MESSAGE]: (key: Key) =>
+ key.name === 'q' && key.ctrl && !key.meta && !key.shift && !key.paste,
[Command.NEWLINE]: (key: Key) =>
key.name === 'return' && (key.ctrl || key.meta || key.paste),
[Command.VOICE_PUSH_TO_TALK]: (key: Key) =>
@@ -237,6 +239,17 @@ describe('keyMatchers', () => {
createKey('return', { paste: true }),
],
},
+ {
+ command: Command.QUEUE_MESSAGE,
+ positive: [createKey('q', { ctrl: true })],
+ negative: [
+ createKey('q'),
+ createKey('q', { ctrl: true, meta: true }),
+ createKey('q', { ctrl: true, shift: true }),
+ createKey('q', { ctrl: true, paste: true }),
+ createKey('return', { ctrl: true }),
+ ],
+ },
{
command: Command.NEWLINE,
positive: [
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index a5b48fb13f0..0c363896513 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -22,7 +22,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Content, GenerateContentResponse, Part } from '@google/genai';
-import { GeminiClient, SendMessageType } from './client.js';
+import { GeminiClient, SendMessageType, type SteerInput } from './client.js';
import { MESSAGE_DISPLAY_DEBOUNCE_MS } from './message-display-buffer.js';
import { getRecentGitStatus } from '../utils/gitUtils.js';
import {
@@ -8213,6 +8213,316 @@ Other open files:
// messageBus.request SHOULD be called for UserPromptSubmit
expect(mockMessageBus.request).toHaveBeenCalled();
});
+
+ it('does not run UserPromptSubmit hooks for same-turn steer input', async () => {
+ const mockMessageBus = {
+ request: vi.fn().mockResolvedValue({ modifiedPrompt: undefined }),
+ response: vi.fn(),
+ };
+ vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
+ vi.mocked(mockConfig.getMessageBus).mockReturnValue(
+ mockMessageBus as unknown as ReturnType,
+ );
+ vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
+ (event: string) => event === 'UserPromptSubmit',
+ );
+
+ const stream = client.sendMessageStream(
+ [{ text: 'focus on error handling' }],
+ new AbortController().signal,
+ 'prompt-steer',
+ { type: SendMessageType.Steer },
+ );
+ for await (const _ of stream) {
+ // consume stream
+ }
+
+ expect(mockMessageBus.request).not.toHaveBeenCalled();
+ });
+
+ it('consumes steer input before running Stop hooks', async () => {
+ const mockMessageBus = {
+ request: vi.fn().mockResolvedValue({ output: undefined }),
+ response: vi.fn(),
+ };
+ vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
+ vi.mocked(mockConfig.getMessageBus).mockReturnValue(
+ mockMessageBus as unknown as ReturnType,
+ );
+ vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
+ (event: string) => event === 'Stop',
+ );
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })(),
+ );
+ const getSteerInput = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce({
+ parts: [{ text: 'focus on error handling' }],
+ accept: vi.fn(),
+ restore: vi.fn(),
+ })
+ .mockResolvedValue(undefined);
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'start the analysis' }],
+ new AbortController().signal,
+ 'prompt-steer-before-stop',
+ { type: SendMessageType.UserQuery, getSteerInput },
+ ),
+ );
+
+ expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
+ expect(getLastTurnRequestText()).toContain('focus on error handling');
+ expect(getSteerInput.mock.invocationCallOrder[0]).toBeLessThan(
+ mockMessageBus.request.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('consumes input queued during a blocking Stop hook before its continuation', async () => {
+ const mockMessageBus = {
+ request: vi
+ .fn()
+ .mockResolvedValueOnce({
+ output: { decision: 'block', reason: 'Keep working' },
+ stopHookCount: 1,
+ })
+ .mockResolvedValue({ output: undefined }),
+ response: vi.fn(),
+ };
+ vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
+ vi.mocked(mockConfig.getMessageBus).mockReturnValue(
+ mockMessageBus as unknown as ReturnType,
+ );
+ vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
+ (event: string) => event === 'Stop',
+ );
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })(),
+ );
+ const getSteerInput = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({
+ parts: [{ text: 'also check the tests' }],
+ accept: vi.fn(),
+ restore: vi.fn(),
+ })
+ .mockResolvedValue(undefined);
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'start the analysis' }],
+ new AbortController().signal,
+ 'prompt-steer-during-stop',
+ { type: SendMessageType.UserQuery, getSteerInput },
+ ),
+ );
+
+ expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
+ expect(getLastTurnRequestText()).toContain('Keep working');
+ expect(getLastTurnRequestText()).toContain('also check the tests');
+ });
+
+ it('uses input queued during next-speaker classification for the continuation', async () => {
+ const { checkNextSpeaker } = await import(
+ '../utils/nextSpeakerChecker.js'
+ );
+ vi.mocked(checkNextSpeaker)
+ .mockResolvedValueOnce({
+ next_speaker: 'model',
+ reasoning: 'continue',
+ })
+ .mockResolvedValue(null);
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })(),
+ );
+ const getSteerInput = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({
+ parts: [{ text: 'focus on the failing test' }],
+ accept: vi.fn(),
+ restore: vi.fn(),
+ })
+ .mockResolvedValue(undefined);
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'start the analysis' }],
+ new AbortController().signal,
+ 'prompt-steer-during-next-speaker',
+ { type: SendMessageType.UserQuery, getSteerInput },
+ ),
+ );
+
+ expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
+ expect(getLastTurnRequestText()).toContain('focus on the failing test');
+ expect(getLastTurnRequestText()).not.toContain('Please continue.');
+ });
+
+ it('does not drain steer input without another model-turn budget', async () => {
+ mockTurnRunFn.mockReturnValue(
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })(),
+ );
+ const getSteerInput = vi.fn<() => Promise>();
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'start the analysis' }],
+ new AbortController().signal,
+ 'prompt-steer-no-budget',
+ { type: SendMessageType.UserQuery, getSteerInput },
+ 1,
+ ),
+ );
+
+ expect(getSteerInput).not.toHaveBeenCalled();
+ });
+
+ it('restores steer input when the continuation fails before history accepts it', async () => {
+ client.getChat().getUserContentPushCount = vi.fn().mockReturnValue(0);
+ mockTurnRunFn
+ .mockImplementationOnce(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })(),
+ )
+ .mockImplementationOnce(() => {
+ throw new Error('setup failed before history push');
+ });
+ const restore = vi.fn();
+ const getSteerInput = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce({
+ parts: [{ text: 'do not lose this' }],
+ accept: vi.fn(),
+ restore,
+ });
+
+ await expect(
+ fromAsync(
+ client.sendMessageStream(
+ [{ text: 'start the analysis' }],
+ new AbortController().signal,
+ 'prompt-steer-restore',
+ { type: SendMessageType.UserQuery, getSteerInput },
+ ),
+ ),
+ ).rejects.toThrow('setup failed before history push');
+
+ expect(restore).toHaveBeenCalledOnce();
+ });
+
+ it('settles an attached ToolResult steer only after history accepts it', async () => {
+ let pushCount = 0;
+ client.getChat().getUserContentPushCount = vi.fn(() => pushCount);
+ mockTurnRunFn.mockImplementation(() => {
+ pushCount = 1;
+ return (async function* () {
+ yield { type: GeminiEventType.Content, value: 'response' };
+ })();
+ });
+ const accept = vi.fn();
+ const restore = vi.fn();
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'tool result plus steer' }],
+ new AbortController().signal,
+ 'prompt-attached-steer-accept',
+ {
+ type: SendMessageType.ToolResult,
+ steerInput: {
+ parts: [{ text: 'steer' }],
+ accept,
+ restore,
+ },
+ },
+ ),
+ );
+
+ expect(accept).toHaveBeenCalledOnce();
+ expect(restore).not.toHaveBeenCalled();
+ });
+
+ it('restores an attached ToolResult steer when history never accepts it', async () => {
+ client.getChat().getUserContentPushCount = vi.fn().mockReturnValue(0);
+ mockTurnRunFn.mockImplementationOnce(() => {
+ throw new Error('setup failed before history push');
+ });
+ const accept = vi.fn();
+ const restore = vi.fn();
+
+ await expect(
+ fromAsync(
+ client.sendMessageStream(
+ [{ text: 'tool result plus steer' }],
+ new AbortController().signal,
+ 'prompt-attached-steer-restore',
+ {
+ type: SendMessageType.ToolResult,
+ steerInput: {
+ parts: [{ text: 'steer' }],
+ accept,
+ restore,
+ },
+ },
+ ),
+ ),
+ ).rejects.toThrow('setup failed before history push');
+
+ expect(accept).not.toHaveBeenCalled();
+ expect(restore).toHaveBeenCalledOnce();
+ });
+
+ it('restores an attached ToolResult steer when UserPromptSubmit blocks it', async () => {
+ client.getChat().getUserContentPushCount = vi.fn().mockReturnValue(0);
+ const mockMessageBus = {
+ request: vi.fn().mockResolvedValue({
+ output: { decision: 'block', reason: 'blocked by hook' },
+ }),
+ response: vi.fn(),
+ };
+ vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
+ vi.mocked(mockConfig.getMessageBus).mockReturnValue(
+ mockMessageBus as unknown as ReturnType,
+ );
+ vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
+ (event: string) => event === 'UserPromptSubmit',
+ );
+ const accept = vi.fn();
+ const restore = vi.fn();
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'tool result plus steer' }],
+ new AbortController().signal,
+ 'prompt-attached-steer-blocked',
+ {
+ type: SendMessageType.ToolResult,
+ steerInput: {
+ parts: [{ text: 'steer' }],
+ accept,
+ restore,
+ },
+ },
+ ),
+ );
+
+ expect(mockTurnRunFn).not.toHaveBeenCalled();
+ expect(accept).not.toHaveBeenCalled();
+ expect(restore).toHaveBeenCalledOnce();
+ });
});
describe('attribution snapshot persistence', () => {
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 78f5baa2831..3710b365507 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -10,6 +10,7 @@ import type {
Content,
GenerateContentConfig,
GenerateContentResponse,
+ Part,
PartListUnion,
Tool,
} from '@google/genai';
@@ -142,6 +143,8 @@ const MAX_RECENT_TOOL_NAMES_FOR_MEMORY = 20;
export enum SendMessageType {
UserQuery = 'userQuery',
ToolResult = 'toolResult',
+ /** User input appended at a sampling boundary within the active turn. */
+ Steer = 'steer',
Retry = 'retry',
Hook = 'hook',
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
@@ -159,6 +162,10 @@ export enum SendMessageType {
export interface SendMessageOptions {
type: SendMessageType;
+ /** Returns user input waiting to steer the active turn at a model boundary. */
+ getSteerInput?: (signal: AbortSignal) => Promise;
+ /** Steer lease already appended to this request, settled after history push. */
+ steerInput?: SteerInput;
/** Track stop hook iterations to prevent infinite loops and display loop info */
stopHookState?: {
iterationCount: number;
@@ -170,6 +177,14 @@ export interface SendMessageOptions {
modelOverride?: string;
}
+export interface SteerInput {
+ parts: Part[];
+ /** Commits UI/recording side effects after the request accepts the input. */
+ accept: () => void;
+ /** Restores the input when the next model request never accepts it. */
+ restore: () => void;
+}
+
const EMPTY_RELEVANT_AUTO_MEMORY_RESULT: RelevantAutoMemoryPromptResult = {
prompt: '',
selectedDocs: [],
@@ -1829,6 +1844,25 @@ export class GeminiClient {
const currentPushCount = () =>
this.getChat().getUserContentPushCount?.() ?? 0;
+ const settleSteerInput = (
+ steerInput: SteerInput | undefined,
+ pushCountBefore: number,
+ ) => {
+ if (!steerInput) return;
+ try {
+ if (currentPushCount() > pushCountBefore) {
+ steerInput.accept();
+ } else {
+ steerInput.restore();
+ }
+ } catch (error) {
+ debugLogger.warn(`Failed to settle steer input: ${error}`);
+ }
+ };
+
+ const attachedSteerInput = options?.steerInput;
+ const attachedSteerPushCount = currentPushCount();
+
const restoreStrippedRetryEntries = () => {
if (strippedRetryEntries.length === 0) {
return;
@@ -1878,6 +1912,7 @@ export class GeminiClient {
const messageBus = this.config.getMessageBus();
if (
messageType !== SendMessageType.Retry &&
+ messageType !== SendMessageType.Steer &&
messageType !== SendMessageType.Cron &&
messageType !== SendMessageType.Notification &&
// Teammate envelopes are machine-driven re-entries like Cron /
@@ -1918,6 +1953,7 @@ export class GeminiClient {
originalPrompt: promptText,
},
};
+ settleSteerInput(attachedSteerInput, attachedSteerPushCount);
return new Turn(this.getChat(), prompt_id);
}
@@ -2153,6 +2189,32 @@ export class GeminiClient {
return new Turn(this.getChat(), prompt_id);
}
+ const takeSteerInput = async (
+ nextTurnBudget: number,
+ ): Promise => {
+ if (
+ nextTurnBudget <= 0 ||
+ !signal ||
+ signal.aborted ||
+ !options?.getSteerInput
+ ) {
+ return undefined;
+ }
+ const maxSessionTurns = this.config.getMaxSessionTurns();
+ if (maxSessionTurns > 0 && this.sessionTurnCount >= maxSessionTurns) {
+ return undefined;
+ }
+ const steerInput = await options.getSteerInput(signal);
+ if (!steerInput || steerInput.parts.length === 0) {
+ return undefined;
+ }
+ if (signal.aborted) {
+ steerInput.restore();
+ return undefined;
+ }
+ return steerInput;
+ };
+
// Auto-compaction happens inside GeminiChat.sendMessageStream and surfaces
// via the `compressed → ChatCompressed` bridge in turn.ts. Manual /compress
// still calls tryCompressChat directly for the full reset (env refresh +
@@ -2547,6 +2609,34 @@ export class GeminiClient {
// Track API completion time for thinking block idle cleanup
this.lastApiCompletionTimestamp = Date.now();
+ if (!turn.pendingToolCalls.length) {
+ const steerTurnBudget = boundedTurns - 1;
+ const steerInput = await takeSteerInput(steerTurnBudget);
+ if (steerInput) {
+ const pushCountBefore = currentPushCount();
+ let steeredTurn: Turn;
+ try {
+ steeredTurn = yield* this.sendMessageStream(
+ steerInput.parts,
+ signal,
+ prompt_id,
+ {
+ ...options,
+ type: SendMessageType.Steer,
+ steerInput: undefined,
+ },
+ steerTurnBudget,
+ );
+ } finally {
+ settleSteerInput(steerInput, pushCountBefore);
+ }
+ if (isTopLevelInteraction)
+ endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
+ normalCompletion = true;
+ return steeredTurn;
+ }
+ }
+
// Fire Stop hook through MessageBus (only if hooks are enabled and registered)
// This must be done before any early returns to ensure hooks are always triggered
if (
@@ -2702,23 +2792,34 @@ export class GeminiClient {
// stopHookBlockingCap / MAX_GOAL_ITERATIONS.
this.loopDetector.reset(prompt_id);
- const continueRequest = [{ text: continueReason }];
const activeGoal = getActiveGoal(this.config.getSessionId());
const hookTurnBudget = activeGoal ? boundedTurns : boundedTurns - 1;
- const hookTurn = yield* this.sendMessageStream(
- continueRequest,
- signal,
- prompt_id,
- {
- type: SendMessageType.Hook,
- modelOverride: options?.modelOverride,
- stopHookState: {
- iterationCount: currentIterationCount,
- reasons: currentReasons,
+ const pendingSteer = await takeSteerInput(hookTurnBudget);
+ const continueRequest: Part[] = [{ text: continueReason }];
+ if (pendingSteer) {
+ continueRequest.push({ text: '\n\n' }, ...pendingSteer.parts);
+ }
+ const pushCountBefore = currentPushCount();
+ let hookTurn: Turn;
+ try {
+ hookTurn = yield* this.sendMessageStream(
+ continueRequest,
+ signal,
+ prompt_id,
+ {
+ type: SendMessageType.Hook,
+ modelOverride: options?.modelOverride,
+ getSteerInput: options?.getSteerInput,
+ stopHookState: {
+ iterationCount: currentIterationCount,
+ reasons: currentReasons,
+ },
},
- },
- hookTurnBudget,
- );
+ hookTurnBudget,
+ );
+ } finally {
+ settleSteerInput(pendingSteer, pushCountBefore);
+ }
if (isTopLevelInteraction)
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
// Preserve the pending prefetch: the inner Hook turn we just
@@ -2780,14 +2881,30 @@ export class GeminiClient {
),
);
if (nextSpeakerCheck?.next_speaker === 'model') {
- const nextRequest = [{ text: 'Please continue.' }];
- const continueTurn = yield* this.sendMessageStream(
- nextRequest,
- signal,
- prompt_id,
- { ...options, type: SendMessageType.Hook },
- boundedTurns - 1,
- );
+ const continueTurnBudget = boundedTurns - 1;
+ const pendingSteer = await takeSteerInput(continueTurnBudget);
+ const nextRequest: Part[] = pendingSteer
+ ? pendingSteer.parts
+ : [{ text: 'Please continue.' }];
+ const pushCountBefore = currentPushCount();
+ let continueTurn: Turn;
+ try {
+ continueTurn = yield* this.sendMessageStream(
+ nextRequest,
+ signal,
+ prompt_id,
+ {
+ ...options,
+ type: pendingSteer
+ ? SendMessageType.Steer
+ : SendMessageType.Hook,
+ steerInput: undefined,
+ },
+ continueTurnBudget,
+ );
+ } finally {
+ settleSteerInput(pendingSteer, pushCountBefore);
+ }
if (isTopLevelInteraction)
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
// Preserve the pending prefetch: same reasoning as the
@@ -2820,6 +2937,7 @@ export class GeminiClient {
normalCompletion = true;
return turn;
} finally {
+ settleSteerInput(attachedSteerInput, attachedSteerPushCount);
restoreStrippedRetryEntries();
// Belt-and-suspenders: close out the MessageDisplay dispatcher on any
// exit the explicit finish() sites above didn't cover (an uncaught