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, }; }