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('