From 61ad9db9c19bc55fa5165231520504eda937ddb4 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 11 Apr 2026 14:38:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20queue=20input=20editing=20?= =?UTF-8?q?=E2=80=94=20pop=20queued=20messages=20for=20editing=20via=20?= =?UTF-8?q?=E2=86=91/ESC=20(#2871)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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. * feat: add ESC trigger, cursor preservation, and progressive hint - 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 * 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. --------- 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.test.tsx | 2 + packages/cli/src/ui/AppContainer.tsx | 7 +- .../src/ui/components/InputPrompt.test.tsx | 166 +++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 41 +++++ .../components/QueuedMessageDisplay.test.tsx | 30 ++++ .../ui/components/QueuedMessageDisplay.tsx | 25 +++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/hooks/useMessageQueue.test.ts | 64 +++++++ packages/cli/src/ui/hooks/useMessageQueue.ts | 13 ++ 15 files changed, 355 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 4177edb3c..385c49b82 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1442,6 +1442,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 7ef40def2..f3092fb95 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1489,6 +1489,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 2789affd1..8bb96c94d 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1123,6 +1123,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 c1a622a9e..c92a3be8b 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1445,6 +1445,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 ae83eb6bf..166cb379e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1367,6 +1367,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 50014ec5d..df87201cb 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1409,6 +1409,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.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 026f52977..1bb7cacd1 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/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 09085846a..511c6ac61 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -781,6 +781,7 @@ export const AppContainer = (props: AppContainerProps) => { addMessage, clearQueue, getQueuedMessagesText, + popAllMessages, drainQueue, } = useMessageQueue({ isConfigInitialized, @@ -789,8 +790,8 @@ export const AppContainer = (props: AppContainerProps) => { }); // Bridge message queue to mid-turn drain via ref. - // drainQueue reads from the synchronous queueRef inside useMessageQueue, - // so it always sees the latest state even between renders. + // 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) @@ -2073,6 +2074,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFinalSubmit, handleRetryLastPrompt: retryLastPrompt, handleClearScreen, + popAllQueuedMessages: popAllMessages, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -2131,6 +2133,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFinalSubmit, retryLastPrompt, handleClearScreen, + popAllMessages, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d12a27569..46973f12d 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'); @@ -34,12 +36,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), })), })); @@ -2530,12 +2533,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 @@ -2645,6 +2650,165 @@ 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\n\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\n\nqueued msg 2', + ); + 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', + ); + // 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(); + }); + + 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( + , + ); + 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 58925e7f8..5bbeba1e0 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 === '') { @@ -831,6 +860,18 @@ export const InputPrompt: React.FC = ({ return true; } + // Pop all queued messages into input when pressing Up arrow at top of input + if ( + !isAttachmentMode && + uiState.messageQueue.length > 0 && + keyMatchers[Command.NAVIGATION_UP](key) && + (buffer.allVisualLines.length === 1 || + (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) + ) { + if (popQueueIntoInput()) return true; + // returned false (queue already cleared) — fall through to history + } + 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 e041092fe..eb578ce5b 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx @@ -73,4 +73,34 @@ 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'); + }); + + 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 a42e9feab..d1c63f5b5 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.tsx @@ -4,9 +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[]; @@ -15,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 @@ -42,6 +60,13 @@ export const QueuedMessageDisplay = ({ )} + {showHint && ( + + + {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 6b73ce3ce..7e82a1978 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -80,6 +80,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 33dbf3211..09cf4de2e 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -232,4 +232,68 @@ 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\n\nMessage 2\n\nMessage 3'); + 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({ + 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 00af564b3..cd1c8a294 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; /** * Atomically drain all queued messages. Returns the drained messages * and clears both the synchronous ref and React state. Safe to call @@ -62,6 +63,17 @@ export function useMessageQueue({ return messageQueue.join('\n\n'); }, [messageQueue]); + // 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 => { + const current = queueRef.current; + if (current.length === 0) return null; + const allText = current.join('\n\n'); + queueRef.current = []; + setMessageQueue([]); + return allText; + }, []); + // Atomically drain all queued messages (synchronous, safe from callbacks). const drainQueue = useCallback((): string[] => { const drained = queueRef.current; @@ -97,6 +109,7 @@ export function useMessageQueue({ addMessage, clearQueue, getQueuedMessagesText, + popAllMessages, drainQueue, }; }