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
4 changes: 4 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export enum Command {

// Text input
SUBMIT = 'submit',
QUEUE_MESSAGE = 'queueMessage',
NEWLINE = 'newline',
VOICE_PUSH_TO_TALK = 'voicePushToTalk',

Expand Down Expand Up @@ -193,6 +194,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
shift: false,
},
],
[Command.QUEUE_MESSAGE]: [
{ key: 'q', ctrl: true, command: false, shift: false, paste: false },
],
// Split into multiple data-driven bindings
// Now also includes shift+enter for multi-line input
[Command.NEWLINE]: [
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1841,7 +1841,10 @@ 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',
'Ctrl+Q to queue · ↑ to edit queued messages':
'Ctrl+Q to queue · ↑ to edit queued messages',
'Enter to steer · Ctrl+Q to queue': 'Enter to steer · Ctrl+Q to queue',
'Queue message for the next turn': 'Queue message for the next turn',

// ============================================================================
// MCP Status
Expand Down Expand Up @@ -2228,6 +2231,7 @@ export default {
'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry',
'No failed request to retry.': 'No failed request to retry.',
'to retry last request': 'to retry last request',
'to queue for the next turn': 'to queue for the next turn',

// ============================================================================
// Coding Plan Authentication
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -1613,7 +1613,11 @@ 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': '按 ↑ 編輯排隊消息',
'Ctrl+Q to queue · ↑ to edit queued messages':
'Ctrl+Q 排到下一輪 · ↑ 編輯排隊消息',
'Enter to steer · Ctrl+Q to queue':
'Enter 追加到目前任務 · Ctrl+Q 排到下一輪',
'Queue message for the next turn': '將消息排到下一輪',
'No MCP servers configured.': '未配置 MCP servers',
'◌ MCP servers are starting up ({{count}} initializing)...':
'◌ MCP servers 正在啟動({{count}} 個正在初始化)...',
Expand Down Expand Up @@ -1825,6 +1829,7 @@ export default {
'Press Ctrl+Y to retry': '按 Ctrl+Y 重試。',
'No failed request to retry.': '沒有可重試的失敗請求。',
'to retry last request': '重試上一次請求',
'to queue for the next turn': '排到下一輪',
'API key cannot be empty.': 'API Key 不能為空。',
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
'無效的 API Key,Coding Plan API Key 均以 "sk-sp-" 開頭,請檢查',
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1763,7 +1763,11 @@ 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': '按 ↑ 编辑排队消息',
'Ctrl+Q to queue · ↑ to edit queued messages':
'Ctrl+Q 排到下一轮 · ↑ 编辑排队消息',
'Enter to steer · Ctrl+Q to queue':
'Enter 追加到当前任务 · Ctrl+Q 排到下一轮',
'Queue message for the next turn': '将消息排到下一轮',

// ============================================================================
// MCP Status
Expand Down Expand Up @@ -2017,6 +2021,7 @@ export default {
'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。',
'No failed request to retry.': '没有可重试的失败请求。',
'to retry last request': '重试上一次请求',
'to queue for the next turn': '排到下一轮',

// ============================================================================
// Coding Plan Authentication
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,48 @@ describe('AppContainer State Management', () => {
).toBe(true);
});

it('marks Ctrl+Q submissions to wait for the idle boundary', () => {
const mockQueueMessage = vi.fn();
const mockSubmitQuery = vi.fn();

mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

capturedUIActions.handleFinalSubmit('/btw next turn', {
deferUntilIdle: true,
});

expect(mockQueueMessage).toHaveBeenCalledWith('/btw next turn', true);
expect(mockSubmitQuery).not.toHaveBeenCalled();
});

it('submits /btw immediately instead of queueing while responding', () => {
const mockSubmitQuery = vi.fn();
const mockQueueMessage = vi.fn();
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,7 @@ export const AppContainer = (props: AppContainerProps) => {

const cancelHandlerRef = useRef<(info?: CancelSubmitInfo) => void>(() => {});
const midTurnDrainRef = useRef<(() => string[]) | null>(null);
const midTurnRestoreRef = useRef<((messages: string[]) => void) | null>(null);

const {
streamingState,
Expand Down Expand Up @@ -1899,6 +1900,7 @@ export const AppContainer = (props: AppContainerProps) => {
logger,
availableTerminalHeightRef,
terminalWidthRef,
midTurnRestoreRef,
);
cancelOngoingRequestRef.current = cancelOngoingRequest;

Expand Down Expand Up @@ -2010,6 +2012,7 @@ export const AppContainer = (props: AppContainerProps) => {
messageQueue,
addMessage,
popAllMessages,
restoreMessages,
drainQueue,
popNextSegment,
} = useMessageQueue();
Expand All @@ -2018,6 +2021,7 @@ export const AppContainer = (props: AppContainerProps) => {
// drainQueue reads the synchronous queueRef inside the hook, so it
// stays consistent with popNextSegment even before React re-renders.
midTurnDrainRef.current = drainQueue;
midTurnRestoreRef.current = restoreMessages;

// Connect remote input watcher to submitQuery for bidirectional sync.
// When an external process writes a command to the input-file,
Expand Down Expand Up @@ -2147,7 +2151,7 @@ export const AppContainer = (props: AppContainerProps) => {

// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
(submittedValue: string, options?: { deferUntilIdle?: boolean }) => {
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
Expand Down Expand Up @@ -2195,6 +2199,10 @@ export const AppContainer = (props: AppContainerProps) => {
`<system-reminder>\n${buildWorkflowSteeringNotice()}\n</system-reminder>\n\n` +
submittedValue;
}
if (options?.deferUntilIdle) {
addMessage(submittedValue, true);
return;
}
Comment on lines +2202 to +2205

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The deferUntilIdle check (Ctrl+Q) is placed after the sub-agent routing guard, so Ctrl+Q on a sub-agent tab silently delivers the message to the sub-agent immediately instead of deferring it for the main agent's next turn.

Failure scenario: The user is viewing a sub-agent tab while the main agent is streaming. The Footer shows "Enter to steer · Ctrl+Q to queue". The user presses Ctrl+Q expecting to queue for the main agent's next turn. Instead, handleFinalSubmit hits the agentViewState.activeView !== 'main' branch first (line ~2156), calls agent.interactiveAgent.enqueueMessage(submittedValue.trim()), and returns — the { deferUntilIdle: true } option is silently discarded and the message lands in the sub-agent's queue.

Suggested change
if (options?.deferUntilIdle) {
addMessage(submittedValue, true);
return;
}
if (options?.deferUntilIdle) {
addMessage(submittedValue, true);
return;
}
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
if (agent) {
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
return;
}
}

— qwen3.7-max via Qwen Code /review

if (
streamingState === StreamingState.Responding &&
isBtwCommand(submittedValue)
Expand Down Expand Up @@ -3969,7 +3977,7 @@ export const AppContainer = (props: AppContainerProps) => {
if (isTranscriptOpenRef.current) return;

// Two-phase: batch plain prompts as one turn, else pop next slash command.
const plainPrompts = drainQueue();
const plainPrompts = drainQueue(true);
const submission =
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
if (submission === null) return;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { VimModeProvider } from '../contexts/VimModeContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { StreamingState } from '../types.js';

vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
Expand Down Expand Up @@ -149,6 +150,18 @@ describe('<Footer />', () => {
expect(lastFrame()).not.toContain('workflow active');
});

it('shows steer and queue shortcuts while the model is responding', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
streamingState: StreamingState.Responding,
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
}),
);

expect(lastFrame()).toContain('Enter to steer · Ctrl+Q to queue');
});

it('shows deferred IDE connection progress', () => {
const { lastFrame } = renderWithWidth(
120,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { GoalPill, useFooterGoalState } from './GoalPill.js';
import { CronPill, useFooterCronTaskCount } from './CronPill.js';
import { t } from '../../i18n/index.js';
import { StreamingState } from '../types.js';

export const Footer: React.FC = () => {
const uiState = useUIState();
Expand Down Expand Up @@ -106,6 +107,10 @@ export const Footer: React.FC = () => {
message: uiState.startupIdeConnectionStatus.message,
})}
</Text>
) : uiState.streamingState === StreamingState.Responding ? (
<Text color={theme.text.secondary}>
{t('Enter to steer · Ctrl+Q to queue')}
</Text>
) : showAutoAcceptIndicator !== undefined ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
) : suppressHint ? null : (
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/Help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const GeneralHelp: React.FC<{ width: number }> = ({ width }) => {
['Tab', t('Accept ghost text or completion')],
['Esc Esc', t('Clear input or cancel operation')],
['Ctrl+L', t('Clear the screen')],
['Ctrl+Q', t('Queue message for the next turn')],
[
process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J',
t('Insert a newline'),
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,23 @@ describe('InputPrompt', () => {
unmount();
});

it('queues the prompt for the next turn on Ctrl+Q', async () => {
props.buffer.setText('send this later');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);

act(() => {
stdin.write('\x11');
});

await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith('send this later', {
deferUntilIdle: true,
});
});
expect(props.buffer.setText).toHaveBeenCalledWith('');
unmount();
});

it('expands large paste placeholders before stashing', () => {
const pending = new Map([
['[Pasted Content 1200 chars]', 'full pasted content'],
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export function expandPendingPastePlaceholders(

export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
onSubmit: (value: string, options?: { deferUntilIdle?: boolean }) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
Expand Down Expand Up @@ -585,7 +585,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetHistoryNavRef = useRef<() => void>(() => {});

const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
(submittedValue: string, deferUntilIdle = false) => {
exportCompletion.reset();
// Expand any large paste placeholders to their full content before submitting
let finalValue = submittedValue;
Expand All @@ -610,7 +610,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
clearPromptStash(targetDir);
onSubmit(finalValue);
if (deferUntilIdle) {
onSubmit(finalValue, { deferUntilIdle: true });
} else {
onSubmit(finalValue);
}

// Reset history navigation so the next Up-arrow starts from the newest
// entry rather than advancing from whatever index the user picked.
Expand Down Expand Up @@ -1626,6 +1630,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}

if (keyMatchers[Command.QUEUE_MESSAGE](key)) {
if (buffer.text.trim()) {
handleSubmitAndClear(buffer.text, true);
}
return true;
}

if (keyMatchers[Command.SUBMIT](key)) {
// When buffer is empty and a suggestion is available, Enter fills the
// buffer instead of submitting — matching Tab/Right-arrow behavior.
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/ui/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const getShortcuts = (): Shortcut[] => [
{ key: 'ctrl+o', description: t('to view transcript') },
{ key: 'ctrl+r', description: t('to search history') },
{ key: 'ctrl+y', description: t('to retry last request') },
{ key: 'ctrl+q', description: t('to queue for the next turn') },
{ key: getPasteKey(), description: t('to paste images') },
{ key: getExternalEditorKey(), description: t('for external editor') },
];
Expand All @@ -56,11 +57,11 @@ const COLUMN_GAP = 4;
const MARGIN_LEFT = 2;
const MARGIN_RIGHT = 2;

// Column distribution for different layouts (5+4+4 for 3 cols, 7+6 for 2 cols)
// Column distribution for different layouts (5+5+4 for 3 cols, 7+7 for 2 cols)
const COLUMN_SPLITS: Record<number, number[]> = {
3: [5, 4, 4],
2: [7, 6],
1: [13],
3: [5, 5, 4],
2: [7, 7],
1: [14],
};

export const KeyboardShortcuts: React.FC = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('QueuedMessageDisplay', () => {
);

const output = lastFrame();
expect(output).toContain('Ctrl+Q to queue');
expect(output).toContain('to edit queued messages');
});

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/QueuedMessageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const QueuedMessageDisplay = ({
{showHint && (
<Box paddingLeft={2}>
<Text dimColor italic>
{t('Press ↑ to edit queued messages')}
{t('Ctrl+Q to queue · ↑ to edit queued messages')}
</Text>
</Box>
)}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export interface UIActions {
onEscapePromptChange: (show: boolean) => void;
onTabConsumerChange: (active: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleFinalSubmit: (
value: string,
options?: { deferUntilIdle?: boolean },
) => void;
handleRetryLastPrompt: () => void;
handleClearScreen: () => void;
popAllQueuedMessages: () => string | null;
Expand Down
Loading
Loading