Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 初期化中)...',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -2021,6 +2026,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
handleClearScreen,
popAllQueuedMessages: popAllMessages,
// Welcome back dialog
handleWelcomeBackSelection,
handleWelcomeBackClose,
Expand Down Expand Up @@ -2078,6 +2084,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleFinalSubmit,
retryLastPrompt,
handleClearScreen,
popAllMessages,
handleWelcomeBackSelection,
handleWelcomeBackClose,
// Subagent dialogs
Expand Down
80 changes: 79 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,22 @@ 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');
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),
})),
}));

Expand Down Expand Up @@ -2540,12 +2543,14 @@ describe('InputPrompt', () => {
let mockUIActions: {
handleRetryLastPrompt: ReturnType<typeof vi.fn>;
temporaryCloseFeedbackDialog: ReturnType<typeof vi.fn>;
popAllQueuedMessages: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
mockUIActions = {
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
popAllQueuedMessages: vi.fn(() => null),
};

// Override the mock for useUIActions
Expand Down Expand Up @@ -2655,6 +2660,79 @@ describe('InputPrompt', () => {
unmount();
});
});

describe('queue input editing', () => {
afterEach(() => {
// Restore default mocks
vi.mocked(useUIState).mockReturnValue({
isFeedbackDialogOpen: false,
messageQueue: [],
} as ReturnType<typeof useUIState>);
vi.mocked(useUIActions).mockReturnValue({
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
popAllQueuedMessages: vi.fn(() => null),
} as unknown as ReturnType<typeof useUIActions>);
});

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<typeof useUIState>);
vi.mocked(useUIActions).mockReturnValue({
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
popAllQueuedMessages: mockPopAll,
} as unknown as ReturnType<typeof useUIActions>);

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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<typeof useUIState>);

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();

stdin.write('\u0010'); // Ctrl+P
await wait();

expect(mockInputHistory.navigateUp).toHaveBeenCalled();
unmount();
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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))
) {
const popped = uiActions.popAllQueuedMessages();
if (popped) {
const currentText = buffer.text;
const newText = currentText ? `${popped}\n${currentText}` : popped;
buffer.setText(newText);
}
return true;
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return true;
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<QueuedMessageDisplay messageQueue={['Some message']} />,
);

const output = lastFrame();
expect(output).toContain('to edit queued messages');
});
});
6 changes: 6 additions & 0 deletions packages/cli/src/ui/components/QueuedMessageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { Box, Text } from 'ink';
import { t } from '../../i18n/index.js';

const MAX_DISPLAYED_QUEUED_MESSAGES = 3;

Expand Down Expand Up @@ -42,6 +43,11 @@ export const QueuedMessageDisplay = ({
</Text>
</Box>
)}
<Box paddingLeft={2}>
<Text dimColor italic>
{t('Press ↑ to edit queued messages')}
</Text>
</Box>
</Box>
);
};
1 change: 1 addition & 0 deletions packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/ui/hooks/useMessageQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\nMessage 2\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([]);
});
});
18 changes: 17 additions & 1 deletion packages/cli/src/ui/hooks/useMessageQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +18,7 @@ export interface UseMessageQueueReturn {
addMessage: (message: string) => void;
clearQueue: () => void;
getQueuedMessagesText: () => string;
popAllMessages: () => string | null;
}

/**
Expand All @@ -31,6 +32,9 @@ export function useMessageQueue({
submitQuery,
}: UseMessageQueueOptions): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// Ref keeps queue in sync for atomic popAllMessages (avoids stale closure reads)
const queueRef = useRef<string[]>(messageQueue);
queueRef.current = messageQueue;
Comment thread
wenshao marked this conversation as resolved.
Resolved

// Add a message to the queue
const addMessage = useCallback((message: string) => {
Expand All @@ -51,6 +55,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');
queueRef.current = [];
setMessageQueue([]);
return allText;
}, []);

// Process queued messages when streaming becomes idle
useEffect(() => {
if (
Expand All @@ -71,5 +86,6 @@ export function useMessageQueue({
addMessage,
clearQueue,
getQueuedMessagesText,
popAllMessages,
};
}
Loading
Loading