From 17147101c20de98d8a9b8bbde6cd45de54efe560 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 4 Apr 2026 11:59:52 +0800 Subject: [PATCH 01/10] feat(cli): add queue input editing via Up arrow key Allow users to edit queued messages by pressing the Up arrow key when the cursor is at the top of the input. All queued messages are popped into the input field for revision before resubmission, reducing wasted turns from incorrect queued instructions. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/AppContainer.tsx | 19 ++++++--- .../cli/src/ui/components/InputPrompt.tsx | 18 ++++++++ .../components/QueuedMessageDisplay.test.tsx | 9 ++++ .../ui/components/QueuedMessageDisplay.tsx | 6 +++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/hooks/useMessageQueue.test.ts | 42 +++++++++++++++++++ packages/cli/src/ui/hooks/useMessageQueue.ts | 10 +++++ .../core/src/utils/asyncMessageQueue.test.ts | 17 ++++++++ packages/core/src/utils/asyncMessageQueue.ts | 8 ++++ 15 files changed, 133 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 92ee2a01e93..33624094e0b 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1439,6 +1439,8 @@ export default { 'Press Ctrl+C again to exit.': 'Drücken Sie erneut Strg+C zum Beenden.', 'Press Ctrl+D again to exit.': 'Drücken Sie erneut Strg+D zum Beenden.', 'Press Esc again to clear.': 'Drücken Sie erneut Esc zum Löschen.', + 'Press ↑ to edit queued messages': + 'Drücken Sie ↑, um Nachrichten in der Warteschlange zu bearbeiten', // ============================================================================ // MCP Status diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index fdc572fcbe3..2f50ec29b10 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1485,6 +1485,7 @@ 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', // ============================================================================ // MCP Status diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 838214f6e16..c83f005d016 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1120,6 +1120,7 @@ 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': '↑ を押してキュー内のメッセージを編集', // MCP Status '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCPサーバーを起動中({{count}} 初期化中)...', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 8fd6a79d178..d81cf2c1bfc 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1442,6 +1442,8 @@ export default { 'Press Ctrl+C again to exit.': 'Pressione Ctrl+C novamente para sair.', 'Press Ctrl+D again to exit.': 'Pressione Ctrl+D novamente para sair.', 'Press Esc again to clear.': 'Pressione Esc novamente para limpar.', + 'Press ↑ to edit queued messages': + 'Pressione ↑ para editar mensagens na fila', // ============================================================================ // MCP Status diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 6b910ff48b5..038d9039ec4 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1364,6 +1364,8 @@ 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': + 'Нажмите ↑ для редактирования сообщений в очереди', // ============================================================================ // Статус MCP diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 357a4ccd328..67148b36508 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1406,6 +1406,7 @@ 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': '按 ↑ 编辑排队消息', // ============================================================================ // MCP Status diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5f5e29e9c4f..8c94eba51ff 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -774,12 +774,17 @@ export const AppContainer = (props: AppContainerProps) => { disabled: agentViewState.activeView !== 'main', }); - const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = - useMessageQueue({ - isConfigInitialized, - streamingState, - submitQuery, - }); + const { + messageQueue, + addMessage, + clearQueue, + getQueuedMessagesText, + popAllMessages, + } = useMessageQueue({ + isConfigInitialized, + streamingState, + submitQuery, + }); // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( @@ -2021,6 +2026,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFinalSubmit, handleRetryLastPrompt: retryLastPrompt, handleClearScreen, + popAllQueuedMessages: popAllMessages, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -2078,6 +2084,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFinalSubmit, retryLastPrompt, handleClearScreen, + popAllMessages, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 56448f85bf3..36e52755615 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -839,6 +839,24 @@ export const InputPrompt: React.FC = ({ return true; } + // Pop all queued messages into input when pressing Up with cursor at top + if ( + uiState.messageQueue.length > 0 && + (keyMatchers[Command.HISTORY_UP](key) || + (keyMatchers[Command.NAVIGATION_UP](key) && + (buffer.allVisualLines.length === 1 || + (buffer.visualCursor[0] === 0 && + buffer.visualScrollRow === 0)))) + ) { + const popped = uiActions.popAllQueuedMessages(); + if (popped) { + const currentText = buffer.text; + const newText = currentText ? `${popped}\n${currentText}` : popped; + buffer.setText(newText); + } + return true; + } + if (keyMatchers[Command.HISTORY_UP](key)) { inputHistory.navigateUp(); return true; diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx index e041092fe19..7c2cf786537 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx @@ -73,4 +73,13 @@ describe('QueuedMessageDisplay', () => { const output = lastFrame(); expect(output).toContain('Message with multiple whitespace'); }); + + it('shows edit hint when queue has messages', () => { + const { lastFrame } = render( + , + ); + + const output = lastFrame(); + 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 a42e9feab10..597b35e4756 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx @@ -5,6 +5,7 @@ */ import { Box, Text } from 'ink'; +import { t } from '../../i18n/index.js'; const MAX_DISPLAYED_QUEUED_MESSAGES = 3; @@ -42,6 +43,11 @@ export const QueuedMessageDisplay = ({ )} + + + {t('Press ↑ to edit queued messages')} + + ); }; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e1a1010b951..65b464ed58c 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -79,6 +79,7 @@ export interface UIActions { handleFinalSubmit: (value: string) => void; handleRetryLastPrompt: () => void; handleClearScreen: () => void; + popAllQueuedMessages: () => string | null; // Welcome back dialog handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void; handleWelcomeBackClose: () => void; diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index 33dbf3211c5..a36cf847b8f 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -232,4 +232,46 @@ describe('useMessageQueue', () => { expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch'); expect(mockSubmitQuery).toHaveBeenCalledTimes(2); }); + + it('should pop all messages from queue', () => { + const { result } = renderHook(() => + useMessageQueue({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }), + ); + + act(() => { + result.current.addMessage('Message 1'); + result.current.addMessage('Message 2'); + result.current.addMessage('Message 3'); + }); + + let popped: string | null = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + expect(popped).toBe('Message 1\nMessage 2\nMessage 3'); + expect(result.current.messageQueue).toEqual([]); + }); + + it('should return null when popping from empty queue', () => { + const { result } = renderHook(() => + useMessageQueue({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }), + ); + + let popped: string | null = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + expect(popped).toBeNull(); + expect(result.current.messageQueue).toEqual([]); + }); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index 517040fecfa..706e083c89a 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -18,6 +18,7 @@ export interface UseMessageQueueReturn { addMessage: (message: string) => void; clearQueue: () => void; getQueuedMessagesText: () => string; + popAllMessages: () => string | null; } /** @@ -51,6 +52,14 @@ export function useMessageQueue({ return messageQueue.join('\n\n'); }, [messageQueue]); + // Pop all messages from the queue for editing + const popAllMessages = useCallback((): string | null => { + if (messageQueue.length === 0) return null; + const allText = messageQueue.join('\n'); + setMessageQueue([]); + return allText; + }, [messageQueue]); + // Process queued messages when streaming becomes idle useEffect(() => { if ( @@ -71,5 +80,6 @@ export function useMessageQueue({ addMessage, clearQueue, getQueuedMessagesText, + popAllMessages, }; } diff --git a/packages/core/src/utils/asyncMessageQueue.test.ts b/packages/core/src/utils/asyncMessageQueue.test.ts index fe54210333c..63935039e88 100644 --- a/packages/core/src/utils/asyncMessageQueue.test.ts +++ b/packages/core/src/utils/asyncMessageQueue.test.ts @@ -72,4 +72,21 @@ describe('AsyncMessageQueue', () => { expect(queue.dequeue()).toBe(i); } }); + + it('should pop the last item with popLast()', () => { + const queue = new AsyncMessageQueue(); + queue.enqueue('a'); + queue.enqueue('b'); + queue.enqueue('c'); + + expect(queue.popLast()).toBe('c'); + expect(queue.size).toBe(2); + expect(queue.dequeue()).toBe('a'); + expect(queue.dequeue()).toBe('b'); + }); + + it('should return null from popLast() when empty', () => { + const queue = new AsyncMessageQueue(); + expect(queue.popLast()).toBeNull(); + }); }); diff --git a/packages/core/src/utils/asyncMessageQueue.ts b/packages/core/src/utils/asyncMessageQueue.ts index 3268718ef00..20a55feb4fb 100644 --- a/packages/core/src/utils/asyncMessageQueue.ts +++ b/packages/core/src/utils/asyncMessageQueue.ts @@ -47,6 +47,14 @@ export class AsyncMessageQueue { return this.items.length; } + /** Remove and return the last item, or null if empty. */ + popLast(): T | null { + if (this.items.length > 0) { + return this.items.pop()!; + } + return null; + } + /** Whether `drain()` has been called. */ get isDrained(): boolean { return this.drained; From 9a419ca306383278fbd6781b23c6e60209ad24cb Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 4 Apr 2026 12:14:15 +0800 Subject: [PATCH 02/10] fix: add missing mocks for InputPrompt tests and attachment mode guard - Add popAllQueuedMessages mock and messageQueue to UIState/UIActions mocks in InputPrompt.test.tsx to fix 25 test failures - Add !isAttachmentMode guard to prevent queue pop from conflicting with attachment navigation - Add single-message popAllMessages test case Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/ui/components/InputPrompt.test.tsx | 5 ++++- .../cli/src/ui/components/InputPrompt.tsx | 1 + .../cli/src/ui/hooks/useMessageQueue.test.ts | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index cd33395f858..4befa1bd5b1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -34,12 +34,13 @@ vi.mock('../hooks/useInputHistory.js'); vi.mock('../hooks/useReverseSearchCompletion.js'); vi.mock('../utils/clipboardUtils.js'); vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })), + useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })), })); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: vi.fn(() => ({ handleRetryLastPrompt: vi.fn(), temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: vi.fn(() => null), })), })); @@ -2540,12 +2541,14 @@ describe('InputPrompt', () => { let mockUIActions: { handleRetryLastPrompt: ReturnType; temporaryCloseFeedbackDialog: ReturnType; + popAllQueuedMessages: ReturnType; }; beforeEach(() => { mockUIActions = { handleRetryLastPrompt: vi.fn(), temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: vi.fn(() => null), }; // Override the mock for useUIActions diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 36e52755615..aedb80175c6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -841,6 +841,7 @@ export const InputPrompt: React.FC = ({ // Pop all queued messages into input when pressing Up with cursor at top if ( + !isAttachmentMode && uiState.messageQueue.length > 0 && (keyMatchers[Command.HISTORY_UP](key) || (keyMatchers[Command.NAVIGATION_UP](key) && diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index a36cf847b8f..d2e94a48a2d 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -257,6 +257,28 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual([]); }); + it('should pop single message without separator', () => { + const { result } = renderHook(() => + useMessageQueue({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }), + ); + + act(() => { + result.current.addMessage('Only message'); + }); + + let popped: string | null = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + expect(popped).toBe('Only message'); + expect(result.current.messageQueue).toEqual([]); + }); + it('should return null when popping from empty queue', () => { const { result } = renderHook(() => useMessageQueue({ From 98002701baf1d9d5780f1e6e4a9cdbfdb95e1620 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 4 Apr 2026 12:23:42 +0800 Subject: [PATCH 03/10] fix: address Copilot review - restrict to Up arrow, add tests, update docs - Only trigger queue pop on NAVIGATION_UP (arrow key), not HISTORY_UP (Ctrl+P), preserving existing Ctrl+P history navigation behavior - Update AsyncMessageQueue class docs to describe popLast() LIFO semantics - Add InputPrompt tests: Up arrow pops queue, Up arrow falls back to history when queue empty, Ctrl+P not intercepted by queue pop Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/ui/components/InputPrompt.test.tsx | 75 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 10 +-- packages/core/src/utils/asyncMessageQueue.ts | 4 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 4befa1bd5b1..97115774f97 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -27,6 +27,8 @@ import * as clipboardUtils from '../utils/clipboardUtils.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import stripAnsi from 'strip-ansi'; import chalk from 'chalk'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCommandCompletion.js'); @@ -2658,6 +2660,79 @@ describe('InputPrompt', () => { unmount(); }); }); + + describe('queue input editing', () => { + afterEach(() => { + // Restore default mocks + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: [], + } as ReturnType); + vi.mocked(useUIActions).mockReturnValue({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: vi.fn(() => null), + } as unknown as ReturnType); + }); + + it('should pop queued messages into input on Up arrow when queue is non-empty', async () => { + const mockPopAll = vi.fn(() => 'queued msg 1\nqueued msg 2'); + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: ['queued msg 1', 'queued msg 2'], + } as ReturnType); + vi.mocked(useUIActions).mockReturnValue({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: mockPopAll, + } as unknown as ReturnType); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B[A'); // Up arrow + await wait(); + + expect(mockPopAll).toHaveBeenCalled(); + expect(props.buffer.setText).toHaveBeenCalledWith( + 'queued msg 1\nqueued msg 2', + ); + unmount(); + }); + + it('should navigate history on Up arrow when queue is empty', async () => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B[A'); // Up arrow + await wait(); + + expect(mockInputHistory.navigateUp).toHaveBeenCalled(); + unmount(); + }); + + it('should not intercept Ctrl+P when queue is non-empty', async () => { + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: ['queued msg'], + } as ReturnType); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u0010'); // Ctrl+P + await wait(); + + expect(mockInputHistory.navigateUp).toHaveBeenCalled(); + unmount(); + }); + }); }); function clean(str: string | undefined): string { if (!str) return ''; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index aedb80175c6..a638abeb5ea 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -839,15 +839,13 @@ export const InputPrompt: React.FC = ({ return true; } - // Pop all queued messages into input when pressing Up with cursor at top + // Pop all queued messages into input when pressing Up arrow at top of input if ( !isAttachmentMode && uiState.messageQueue.length > 0 && - (keyMatchers[Command.HISTORY_UP](key) || - (keyMatchers[Command.NAVIGATION_UP](key) && - (buffer.allVisualLines.length === 1 || - (buffer.visualCursor[0] === 0 && - buffer.visualScrollRow === 0)))) + keyMatchers[Command.NAVIGATION_UP](key) && + (buffer.allVisualLines.length === 1 || + (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) ) { const popped = uiActions.popAllQueuedMessages(); if (popped) { diff --git a/packages/core/src/utils/asyncMessageQueue.ts b/packages/core/src/utils/asyncMessageQueue.ts index 20a55feb4fb..3735a4bd7ef 100644 --- a/packages/core/src/utils/asyncMessageQueue.ts +++ b/packages/core/src/utils/asyncMessageQueue.ts @@ -16,7 +16,9 @@ * A generic non-blocking message queue. * * - `enqueue(item)` adds an item. Silently dropped after `drain()`. - * - `dequeue()` returns the next item, or `null` if empty. + * - `dequeue()` returns the next item (FIFO), or `null` if empty. + * - `popLast()` returns the last item (LIFO), or `null` if empty. + * Useful for retrieving the most recently queued item for editing. * - `drain()` signals that no more items will be enqueued. */ export class AsyncMessageQueue { From 17f3fb6ab499e92a8bfcf80d8aa40fdb551a2af3 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 4 Apr 2026 13:03:23 +0800 Subject: [PATCH 04/10] fix: update fileoverview docs and make popAllMessages atomic via ref - Update @fileoverview to describe FIFO+LIFO capability instead of "Simple FIFO queue" - Use queueRef to make popAllMessages atomic, preventing duplicate pops from key auto-repeat before React re-renders Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/hooks/useMessageQueue.ts | 16 +++++++++++----- packages/core/src/utils/asyncMessageQueue.ts | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index 706e083c89a..e10d6d343fb 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { StreamingState } from '../types.js'; export interface UseMessageQueueOptions { @@ -32,6 +32,9 @@ export function useMessageQueue({ submitQuery, }: UseMessageQueueOptions): UseMessageQueueReturn { const [messageQueue, setMessageQueue] = useState([]); + // Ref keeps queue in sync for atomic popAllMessages (avoids stale closure reads) + const queueRef = useRef(messageQueue); + queueRef.current = messageQueue; // Add a message to the queue const addMessage = useCallback((message: string) => { @@ -52,13 +55,16 @@ export function useMessageQueue({ return messageQueue.join('\n\n'); }, [messageQueue]); - // Pop all messages from the queue for editing + // Pop all messages from the queue for editing (atomic via ref to prevent + // duplicate pops from key auto-repeat before React re-renders) const popAllMessages = useCallback((): string | null => { - if (messageQueue.length === 0) return null; - const allText = messageQueue.join('\n'); + const current = queueRef.current; + if (current.length === 0) return null; + const allText = current.join('\n'); + queueRef.current = []; setMessageQueue([]); return allText; - }, [messageQueue]); + }, []); // Process queued messages when streaming becomes idle useEffect(() => { diff --git a/packages/core/src/utils/asyncMessageQueue.ts b/packages/core/src/utils/asyncMessageQueue.ts index 3735a4bd7ef..3a969589e81 100644 --- a/packages/core/src/utils/asyncMessageQueue.ts +++ b/packages/core/src/utils/asyncMessageQueue.ts @@ -7,9 +7,9 @@ /** * @fileoverview Generic non-blocking message queue. * - * Simple FIFO queue for producer/consumer patterns. Dequeue is - * non-blocking — returns null when empty. The consumer decides - * when and how to process items. + * Queue for producer/consumer patterns with FIFO dequeue and LIFO + * pop. All operations are non-blocking — they return null when empty. + * The consumer decides when and how to process items. */ /** From f2537d2cb7e55dbdd8b014e1f309460319ec88b8 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 4 Apr 2026 13:25:06 +0800 Subject: [PATCH 05/10] fix: sync queueRef in addMessage/clearQueue and fall through on null pop - Update queueRef inside addMessage setter and clearQueue to keep ref in sync between renders, preventing stale reads after clearQueue - When popAllQueuedMessages returns null (queue already cleared), fall through to normal history navigation instead of consuming the key Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/components/InputPrompt.tsx | 3 ++- packages/cli/src/ui/hooks/useMessageQueue.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index a638abeb5ea..54db40f92fe 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -852,8 +852,9 @@ export const InputPrompt: React.FC = ({ const currentText = buffer.text; const newText = currentText ? `${popped}\n${currentText}` : popped; buffer.setText(newText); + return true; } - return true; + // popped is null (queue already cleared) — fall through to history } if (keyMatchers[Command.HISTORY_UP](key)) { diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index e10d6d343fb..aeb5bf8bfd1 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -40,12 +40,17 @@ export function useMessageQueue({ const addMessage = useCallback((message: string) => { const trimmedMessage = message.trim(); if (trimmedMessage.length > 0) { - setMessageQueue((prev) => [...prev, trimmedMessage]); + setMessageQueue((prev) => { + const next = [...prev, trimmedMessage]; + queueRef.current = next; + return next; + }); } }, []); // Clear the entire queue const clearQueue = useCallback(() => { + queueRef.current = []; setMessageQueue([]); }, []); From 1fb11535566f70ccb76f9fd75d5e14cebf9ad652 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 8 Apr 2026 19:06:15 +0800 Subject: [PATCH 06/10] fix: remove dead popLast() and align popAllMessages separator to \n\n - Remove unused AsyncMessageQueue.popLast() (no production callers) - Change popAllMessages join separator from \n to \n\n for consistency with getQueuedMessagesText and auto-submit behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/components/InputPrompt.test.tsx | 4 ++-- .../cli/src/ui/hooks/useMessageQueue.test.ts | 2 +- packages/cli/src/ui/hooks/useMessageQueue.ts | 2 +- .../core/src/utils/asyncMessageQueue.test.ts | 17 ----------------- packages/core/src/utils/asyncMessageQueue.ts | 18 ++++-------------- 5 files changed, 8 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 92e9c01c824..e034f9d98bb 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2663,7 +2663,7 @@ describe('InputPrompt', () => { }); it('should pop queued messages into input on Up arrow when queue is non-empty', async () => { - const mockPopAll = vi.fn(() => 'queued msg 1\nqueued msg 2'); + const mockPopAll = vi.fn(() => 'queued msg 1\n\nqueued msg 2'); vi.mocked(useUIState).mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: ['queued msg 1', 'queued msg 2'], @@ -2684,7 +2684,7 @@ describe('InputPrompt', () => { expect(mockPopAll).toHaveBeenCalled(); expect(props.buffer.setText).toHaveBeenCalledWith( - 'queued msg 1\nqueued msg 2', + 'queued msg 1\n\nqueued msg 2', ); unmount(); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index d2e94a48a2d..09cf4de2e53 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -253,7 +253,7 @@ describe('useMessageQueue', () => { popped = result.current.popAllMessages(); }); - expect(popped).toBe('Message 1\nMessage 2\nMessage 3'); + expect(popped).toBe('Message 1\n\nMessage 2\n\nMessage 3'); expect(result.current.messageQueue).toEqual([]); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index e4ddbbd97fc..cd1c8a294ea 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -68,7 +68,7 @@ export function useMessageQueue({ const popAllMessages = useCallback((): string | null => { const current = queueRef.current; if (current.length === 0) return null; - const allText = current.join('\n'); + const allText = current.join('\n\n'); queueRef.current = []; setMessageQueue([]); return allText; diff --git a/packages/core/src/utils/asyncMessageQueue.test.ts b/packages/core/src/utils/asyncMessageQueue.test.ts index 63935039e88..fe54210333c 100644 --- a/packages/core/src/utils/asyncMessageQueue.test.ts +++ b/packages/core/src/utils/asyncMessageQueue.test.ts @@ -72,21 +72,4 @@ describe('AsyncMessageQueue', () => { expect(queue.dequeue()).toBe(i); } }); - - it('should pop the last item with popLast()', () => { - const queue = new AsyncMessageQueue(); - queue.enqueue('a'); - queue.enqueue('b'); - queue.enqueue('c'); - - expect(queue.popLast()).toBe('c'); - expect(queue.size).toBe(2); - expect(queue.dequeue()).toBe('a'); - expect(queue.dequeue()).toBe('b'); - }); - - it('should return null from popLast() when empty', () => { - const queue = new AsyncMessageQueue(); - expect(queue.popLast()).toBeNull(); - }); }); diff --git a/packages/core/src/utils/asyncMessageQueue.ts b/packages/core/src/utils/asyncMessageQueue.ts index 3a969589e81..3268718ef00 100644 --- a/packages/core/src/utils/asyncMessageQueue.ts +++ b/packages/core/src/utils/asyncMessageQueue.ts @@ -7,18 +7,16 @@ /** * @fileoverview Generic non-blocking message queue. * - * Queue for producer/consumer patterns with FIFO dequeue and LIFO - * pop. All operations are non-blocking — they return null when empty. - * The consumer decides when and how to process items. + * Simple FIFO queue for producer/consumer patterns. Dequeue is + * non-blocking — returns null when empty. The consumer decides + * when and how to process items. */ /** * A generic non-blocking message queue. * * - `enqueue(item)` adds an item. Silently dropped after `drain()`. - * - `dequeue()` returns the next item (FIFO), or `null` if empty. - * - `popLast()` returns the last item (LIFO), or `null` if empty. - * Useful for retrieving the most recently queued item for editing. + * - `dequeue()` returns the next item, or `null` if empty. * - `drain()` signals that no more items will be enqueued. */ export class AsyncMessageQueue { @@ -49,14 +47,6 @@ export class AsyncMessageQueue { return this.items.length; } - /** Remove and return the last item, or null if empty. */ - popLast(): T | null { - if (this.items.length > 0) { - return this.items.pop()!; - } - return null; - } - /** Whether `drain()` has been called. */ get isDrained(): boolean { return this.drained; From adbbd41d8b76013fe47e053a38109b56dee06930 Mon Sep 17 00:00:00 2001 From: wenshao Date: Wed, 8 Apr 2026 19:18:32 +0800 Subject: [PATCH 07/10] fix: use hook's drainQueue for mid-turn drain to prevent double-consumption race The midTurnDrainRef previously used a separate messageQueueRef (synced from React state), while popAllMessages uses the hook's internal queueRef. If a tool completed between popAllMessages clearing queueRef and React re-rendering, midTurnDrainRef would read stale data and consume the same messages a second time. Switching to the hook's drainQueue makes both paths read from the same synchronous ref, eliminating the window for double consumption. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/AppContainer.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d2d395a0f86..dcbbc938cd4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -783,6 +783,7 @@ export const AppContainer = (props: AppContainerProps) => { clearQueue, getQueuedMessagesText, popAllMessages, + drainQueue, } = useMessageQueue({ isConfigInitialized, streamingState, @@ -790,16 +791,9 @@ export const AppContainer = (props: AppContainerProps) => { }); // Bridge message queue to mid-turn drain via ref. - // Sync ref on every render so the drain callback always reads latest state. - const messageQueueRef = useRef(messageQueue); - messageQueueRef.current = messageQueue; - midTurnDrainRef.current = () => { - const queue = messageQueueRef.current; - if (queue.length === 0) return []; - messageQueueRef.current = []; - clearQueue(); - return [...queue]; - }; + // drainQueue reads the synchronous queueRef inside the hook, so it + // stays consistent with popAllMessages even before React re-renders. + midTurnDrainRef.current = drainQueue; // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( From 783f363ed4f75e19c7a6c67baefeb6dcb29a1bc5 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 9 Apr 2026 21:39:11 +0800 Subject: [PATCH 08/10] fix: add missing popAllMessages mock and prepend branch test Add popAllMessages to useMessageQueue mock in AppContainer tests. Add test for prepending queued messages before existing input text. --- packages/cli/src/ui/AppContainer.test.tsx | 2 ++ .../src/ui/components/InputPrompt.test.tsx | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 026f52977f3..1bb7cacd112 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -243,6 +243,7 @@ describe('AppContainer State Management', () => { addMessage: vi.fn(), clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), }); mockedUseAutoAcceptIndicator.mockReturnValue(false); @@ -456,6 +457,7 @@ describe('AppContainer State Management', () => { addMessage: mockQueueMessage, clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b86b566a2b7..6b81c7f54ab 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2679,6 +2679,35 @@ describe('InputPrompt', () => { unmount(); }); + it('should prepend queued messages before existing input text', async () => { + const mockPopAll = vi.fn(() => 'queued msg'); + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: ['queued msg'], + } as ReturnType); + vi.mocked(useUIActions).mockReturnValue({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: mockPopAll, + } as unknown as ReturnType); + + // Set existing text in buffer + props.buffer.text = 'existing input'; + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B[A'); // Up arrow + await wait(); + + expect(props.buffer.setText).toHaveBeenCalledWith( + 'queued msg\nexisting input', + ); + unmount(); + }); + it('should navigate history on Up arrow when queue is empty', async () => { const { stdin, unmount } = renderWithProviders( , From 073d9549d221571f94ecdc8c8c622d92b9d40c27 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 9 Apr 2026 22:02:43 +0800 Subject: [PATCH 09/10] feat: add ESC trigger, cursor preservation, and progressive hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ESC pops queued messages before double-ESC clear logic - Cursor stays at user's editing position after pop via moveToOffset - Extract popQueueIntoInput helper to share logic between Up and ESC - QueuedMessageDisplay hint hides after 3 empty→non-empty transitions --- .../src/ui/components/InputPrompt.test.tsx | 29 ++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 39 +++++++++++++++---- .../components/QueuedMessageDisplay.test.tsx | 21 ++++++++++ .../ui/components/QueuedMessageDisplay.tsx | 29 +++++++++++--- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 6b81c7f54ab..0aa5583aff9 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2705,6 +2705,35 @@ describe('InputPrompt', () => { expect(props.buffer.setText).toHaveBeenCalledWith( 'queued msg\nexisting input', ); + // Cursor should be positioned at start of existing text + expect(props.buffer.moveToOffset).toHaveBeenCalledWith( + 'queued msg'.length + 1, // popped length + newline + ); + unmount(); + }); + + it('should pop queued messages on ESC when queue is non-empty', async () => { + const mockPopAll = vi.fn(() => 'queued msg'); + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: ['queued msg'], + } as ReturnType); + vi.mocked(useUIActions).mockReturnValue({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: mockPopAll, + } as unknown as ReturnType); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B'); // ESC + await wait(); + + expect(mockPopAll).toHaveBeenCalled(); + expect(props.buffer.setText).toHaveBeenCalledWith('queued msg'); unmount(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b25613c1370..b1530182d77 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -514,6 +514,26 @@ export const InputPrompt: React.FC = ({ } } + // Helper: pop all queued messages into the input buffer, + // preserving cursor position relative to existing text. + const popQueueIntoInput = (): boolean => { + const popped = uiActions.popAllQueuedMessages(); + if (!popped) return false; + const currentText = buffer.text; + if (currentText) { + const currentCursorOffset = logicalPosToOffset( + buffer.lines, + buffer.cursor[0], + buffer.cursor[1], + ); + buffer.setText(`${popped}\n${currentText}`); + buffer.moveToOffset(popped.length + 1 + currentCursorOffset); + } else { + buffer.setText(popped); + } + return true; + }; + // Reset ESC count and hide prompt on any non-ESC key if (key.name !== 'escape') { if (escPressCount > 0 || showEscapePrompt) { @@ -596,6 +616,15 @@ export const InputPrompt: React.FC = ({ return true; } + // Pop queued messages into input on ESC (before double-ESC clear) + if (!isAttachmentMode && uiState.messageQueue.length > 0) { + if (popQueueIntoInput()) { + resetEscapeState(); + return true; + } + // returned false (queue already cleared) — fall through + } + // Handle double ESC for clearing input if (escPressCount === 0) { if (buffer.text === '') { @@ -837,14 +866,8 @@ export const InputPrompt: React.FC = ({ (buffer.allVisualLines.length === 1 || (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) ) { - const popped = uiActions.popAllQueuedMessages(); - if (popped) { - const currentText = buffer.text; - const newText = currentText ? `${popped}\n${currentText}` : popped; - buffer.setText(newText); - return true; - } - // popped is null (queue already cleared) — fall through to history + if (popQueueIntoInput()) return true; + // returned false (queue already cleared) — fall through to history } if (keyMatchers[Command.HISTORY_UP](key)) { diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx index 7c2cf786537..eb578ce5b57 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx @@ -82,4 +82,25 @@ describe('QueuedMessageDisplay', () => { const output = lastFrame(); expect(output).toContain('to edit queued messages'); }); + + it('hides edit hint after showing it enough times', () => { + // Render with non-empty queue, then empty, then non-empty — repeat + // to simulate multiple queue cycles. Hint should disappear after 3. + const { lastFrame, rerender } = render( + , + ); + expect(lastFrame()).toContain('to edit queued messages'); // 1st + + rerender(); + rerender(); + expect(lastFrame()).toContain('to edit queued messages'); // 2nd + + rerender(); + rerender(); + expect(lastFrame()).toContain('to edit queued messages'); // 3rd + + rerender(); + rerender(); + expect(lastFrame()).not.toContain('to edit queued messages'); // 4th — hidden + }); }); diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx index 597b35e4756..d1c63f5b53d 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx @@ -4,10 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useRef } from 'react'; import { Box, Text } from 'ink'; import { t } from '../../i18n/index.js'; const MAX_DISPLAYED_QUEUED_MESSAGES = 3; +const NUM_TIMES_QUEUE_HINT_SHOWN = 3; export interface QueuedMessageDisplayProps { messageQueue: string[]; @@ -16,10 +18,25 @@ export interface QueuedMessageDisplayProps { export const QueuedMessageDisplay = ({ messageQueue, }: QueuedMessageDisplayProps) => { + // Track how many times the edit hint has been shown (per session). + // Once the user has seen it enough times, hide it. + const hintSeenCountRef = useRef(0); + const wasEmptyRef = useRef(true); + if (messageQueue.length === 0) { + wasEmptyRef.current = true; return null; } + // Increment counter only on queue transition from empty → non-empty + // (not on every re-render while queue stays non-empty). + if (wasEmptyRef.current) { + hintSeenCountRef.current++; + wasEmptyRef.current = false; + } + + const showHint = hintSeenCountRef.current <= NUM_TIMES_QUEUE_HINT_SHOWN; + return ( {messageQueue @@ -43,11 +60,13 @@ export const QueuedMessageDisplay = ({ )} - - - {t('Press ↑ to edit queued messages')} - - + {showHint && ( + + + {t('Press ↑ to edit queued messages')} + + + )} ); }; From 6215b70aedf9a73043bd9dd1159c7ca7024f1629 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 9 Apr 2026 22:09:38 +0800 Subject: [PATCH 10/10] test: add null-pop fallthrough test for queue race condition Verify that when React state shows non-empty queue but the ref is already drained (popAllQueuedMessages returns null), Up arrow falls through to normal history navigation instead of getting stuck. --- .../src/ui/components/InputPrompt.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 0aa5583aff9..83012d87f88 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2737,6 +2737,34 @@ describe('InputPrompt', () => { unmount(); }); + it('should fall through to history when pop returns null (race condition)', async () => { + // Simulate: React state says queue is non-empty, but queueRef was + // already drained by another pop/drain — popAllQueuedMessages returns null. + const mockPopAll = vi.fn(() => null); + vi.mocked(useUIState).mockReturnValue({ + isFeedbackDialogOpen: false, + messageQueue: ['stale msg'], + } as ReturnType); + vi.mocked(useUIActions).mockReturnValue({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: mockPopAll, + } as unknown as ReturnType); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B[A'); // Up arrow + await wait(); + + expect(mockPopAll).toHaveBeenCalled(); + expect(props.buffer.setText).not.toHaveBeenCalled(); + expect(mockInputHistory.navigateUp).toHaveBeenCalled(); + unmount(); + }); + it('should navigate history on Up arrow when queue is empty', async () => { const { stdin, unmount } = renderWithProviders( ,