Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -1441,6 +1441,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 @@ -1487,6 +1487,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 @@ -1122,6 +1122,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 @@ -1444,6 +1444,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 @@ -1366,6 +1366,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 @@ -1407,6 +1407,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
2 changes: 2 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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([]),
});

Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@ export const AppContainer = (props: AppContainerProps) => {
addMessage,
clearQueue,
getQueuedMessagesText,
popAllMessages,
drainQueue,
} = useMessageQueue({
isConfigInitialized,
Expand All @@ -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)
Expand Down Expand Up @@ -2072,6 +2073,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
handleClearScreen,
popAllQueuedMessages: popAllMessages,
// Welcome back dialog
handleWelcomeBackSelection,
handleWelcomeBackClose,
Expand Down Expand Up @@ -2129,6 +2131,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleFinalSubmit,
retryLastPrompt,
handleClearScreen,
popAllMessages,
handleWelcomeBackSelection,
handleWelcomeBackClose,
// Subagent dialogs
Expand Down
166 changes: 165 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 @@ -2517,12 +2520,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 @@ -2632,6 +2637,165 @@ 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\n\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\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<typeof useUIState>);
vi.mocked(useUIActions).mockReturnValue({
handleRetryLastPrompt: vi.fn(),
temporaryCloseFeedbackDialog: vi.fn(),
popAllQueuedMessages: mockPopAll,
} as unknown as ReturnType<typeof useUIActions>);

// Set existing text in buffer
props.buffer.text = 'existing input';

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
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<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'); // 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<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).not.toHaveBeenCalled();
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
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
41 changes: 41 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}

// 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) {
Expand Down Expand Up @@ -596,6 +616,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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 === '') {
Expand Down Expand Up @@ -829,6 +858,18 @@ 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))
) {
if (popQueueIntoInput()) return true;
// returned false (queue already cleared) — fall through to history
}

if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return true;
Expand Down
Loading
Loading