diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 031a9b61e..2c4392cf9 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -28,6 +28,7 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
+import { ToolCallStatus } from './types.js';
import { useContext } from 'react';
// Mock useStdout to capture terminal title writes
@@ -245,6 +246,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue(null),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseGitBranchName.mockReturnValue('main');
@@ -459,6 +461,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@@ -476,6 +479,44 @@ describe('AppContainer State Management', () => {
expect(mockQueueMessage).not.toHaveBeenCalled();
});
+ it('submits slash commands immediately instead of queueing while idle', () => {
+ const mockSubmitQuery = vi.fn();
+ const mockQueueMessage = vi.fn();
+
+ mockedUseGeminiStream.mockReturnValue({
+ streamingState: 'idle',
+ submitQuery: mockSubmitQuery,
+ initError: null,
+ pendingHistoryItems: [],
+ thought: null,
+ cancelOngoingRequest: vi.fn(),
+ retryLastPrompt: vi.fn(),
+ });
+ 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(
+ ,
+ );
+
+ capturedUIActions.handleFinalSubmit('/model');
+
+ expect(mockSubmitQuery).toHaveBeenCalledWith('/model');
+ expect(mockQueueMessage).not.toHaveBeenCalled();
+ });
+
it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])(
'routes bare "%s" to /quit instead of sending as a message',
(command) => {
@@ -497,6 +538,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@@ -577,6 +619,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@@ -605,6 +648,7 @@ describe('AppContainer State Management', () => {
it('moves queued follow-up messages into an empty buffer on cancel', async () => {
const mockSetText = vi.fn();
const mockPopAllMessages = vi.fn().mockReturnValue('queued follow-up');
+ const mockClearQueue = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
@@ -626,10 +670,11 @@ describe('AppContainer State Management', () => {
mockedUseMessageQueue.mockReturnValue({
messageQueue: ['queued follow-up'],
addMessage: vi.fn(),
- clearQueue: vi.fn(),
+ clearQueue: mockClearQueue,
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: mockPopAllMessages,
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
+ popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(
@@ -653,6 +698,75 @@ describe('AppContainer State Management', () => {
expect.stringContaining('the previous prompt'),
);
expect(mockPopAllMessages).toHaveBeenCalled();
+ // popAllForEdit drains the queue internally, so the cancel handler
+ // does not need to call clearQueue separately on this path.
+ expect(mockClearQueue).not.toHaveBeenCalled();
+ });
+
+ it('drops the queue when cancelling during tool execution', async () => {
+ // Simulates: user asks for a shell tool (e.g. sleep 30), queues
+ // `/model` and `hi` while the tool is running, then hits Ctrl+C.
+ // The cancel must clear BOTH the buffer and the queue so that
+ // `hi` does not auto-fire once the tool settles and the app
+ // returns to idle.
+ const mockSetText = vi.fn();
+ const mockClearQueue = vi.fn();
+ mockedUseTextBuffer.mockReturnValue({
+ text: '',
+ setText: mockSetText,
+ });
+ installCancelCapture({
+ streamingState: 'responding',
+ submitQuery: vi.fn(),
+ initError: null,
+ pendingHistoryItems: [
+ {
+ type: 'tool_group',
+ tools: [
+ {
+ callId: 'call-1',
+ name: 'run_shell_command',
+ description: 'sleep 30',
+ status: ToolCallStatus.Executing,
+ resultDisplay: undefined,
+ confirmationDetails: undefined,
+ renderOutputAsMarkdown: false,
+ },
+ ],
+ },
+ ],
+ thought: null,
+ cancelOngoingRequest: vi.fn(),
+ retryLastPrompt: vi.fn(),
+ });
+ mockedUseMessageQueue.mockReturnValue({
+ messageQueue: ['/model', 'hi'],
+ addMessage: vi.fn(),
+ clearQueue: mockClearQueue,
+ getQueuedMessagesText: vi.fn().mockReturnValue('/model\n\nhi'),
+ popAllMessages: vi.fn().mockReturnValue('/model'),
+ drainQueue: vi.fn().mockReturnValue([]),
+ popNextSegment: vi.fn().mockReturnValue('/model'),
+ });
+
+ render(
+ ,
+ );
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ triggerCancel();
+
+ // Buffer cleared and queue dropped — same "abort and redirect"
+ // contract as the non-tool cancel path.
+ expect(mockSetText).toHaveBeenCalledWith('');
+ expect(mockClearQueue).toHaveBeenCalled();
});
it('preserves an in-progress draft when restoring queued messages on cancel', async () => {
@@ -680,6 +794,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: vi.fn().mockReturnValue('queued follow-up'),
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
+ popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index a0786b5ff..e8156b59e 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -88,7 +88,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useVim } from './hooks/vim.js';
-import { isBtwCommand } from './utils/commandUtils.js';
+import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
@@ -882,16 +882,18 @@ export const AppContainer = (props: AppContainerProps) => {
disabled: agentViewState.activeView !== 'main',
});
- const { messageQueue, addMessage, popAllMessages, drainQueue } =
- useMessageQueue({
- isConfigInitialized,
- streamingState,
- submitQuery,
- });
+ const {
+ messageQueue,
+ addMessage,
+ clearQueue,
+ popAllMessages,
+ drainQueue,
+ popNextSegment,
+ } = useMessageQueue();
// Bridge message queue to mid-turn drain via ref.
// drainQueue reads the synchronous queueRef inside the hook, so it
- // stays consistent with popAllMessages even before React re-renders.
+ // stays consistent with popNextSegment even before React re-renders.
midTurnDrainRef.current = drainQueue;
// Connect remote input watcher to submitQuery for bidirectional sync.
@@ -1167,6 +1169,14 @@ export const AppContainer = (props: AppContainerProps) => {
speculationRef.current = IDLE_SPECULATION;
}
+ if (
+ streamingState === StreamingState.Idle &&
+ isSlashCommand(submittedValue)
+ ) {
+ void submitQuery(submittedValue);
+ return;
+ }
+
addMessage(submittedValue);
},
[
@@ -1213,28 +1223,22 @@ export const AppContainer = (props: AppContainerProps) => {
...pendingGeminiHistoryItems,
];
if (isToolExecuting(pendingHistoryItems)) {
- buffer.setText(''); // Just clear the prompt
+ // Tool-cancel: drop both buffer and queue so nothing auto-fires later.
+ buffer.setText('');
+ clearQueue();
return;
}
- // Move any queued follow-up messages back into the buffer so the user
- // can edit or resubmit them. Otherwise leave the buffer alone — in
- // particular, do NOT repopulate it with the previous prompt; the user
- // can still recall it via history navigation (Up/Ctrl+P).
- //
- // popAllMessages is atomic via the queue's synchronous ref, matching
- // the drain behavior used during tool completion.
+ // Restore queued input joined into the buffer for editing.
const popped = popAllMessages();
if (popped) {
const currentText = buffer.text;
- // Preserve any in-progress draft the user typed since submitting (this
- // is reachable via Ctrl+C cancel, which fires regardless of buffer
- // content). Mirrors the popQueueIntoInput convention in InputPrompt.
buffer.setText(currentText ? `${popped}\n${currentText}` : popped);
}
}, [
buffer,
popAllMessages,
+ clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
]);
@@ -2038,6 +2042,39 @@ export const AppContainer = (props: AppContainerProps) => {
isExtensionsManagerDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
+ // Drain queued messages when idle. `queueDrainNonce` re-fires the effect
+ // after each submission settles so multi-step queues drain end-to-end.
+ const queueDrainingRef = useRef(false);
+ const [queueDrainNonce, setQueueDrainNonce] = useState(0);
+ useEffect(() => {
+ if (queueDrainingRef.current) return;
+ if (!isConfigInitialized) return;
+ if (streamingState !== StreamingState.Idle) return;
+ if (dialogsVisible) return;
+ if (messageQueue.length === 0) return;
+
+ // Two-phase: batch plain prompts as one turn, else pop next slash command.
+ const plainPrompts = drainQueue();
+ const submission =
+ plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
+ if (submission === null) return;
+
+ queueDrainingRef.current = true;
+ Promise.resolve(submitQuery(submission)).finally(() => {
+ queueDrainingRef.current = false;
+ setQueueDrainNonce((n) => n + 1);
+ });
+ }, [
+ isConfigInitialized,
+ streamingState,
+ dialogsVisible,
+ messageQueue,
+ drainQueue,
+ popNextSegment,
+ submitQuery,
+ queueDrainNonce,
+ ]);
+
const {
isFeedbackDialogOpen,
openFeedbackDialog,
diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts
index 09cf4de2e..6d783d0d8 100644
--- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts
+++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts
@@ -7,13 +7,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessageQueue } from './useMessageQueue.js';
-import { StreamingState } from '../types.js';
describe('useMessageQueue', () => {
- let mockSubmitQuery: ReturnType;
-
beforeEach(() => {
- mockSubmitQuery = vi.fn();
vi.useFakeTimers();
});
@@ -23,26 +19,14 @@ describe('useMessageQueue', () => {
});
it('should initialize with empty queue', () => {
- const { result } = renderHook(() =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQuery,
- }),
- );
+ const { result } = renderHook(() => useMessageQueue());
expect(result.current.messageQueue).toEqual([]);
expect(result.current.getQueuedMessagesText()).toBe('');
});
it('should add messages to queue', () => {
- const { result } = renderHook(() =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- }),
- );
+ const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Test message 1');
@@ -56,13 +40,7 @@ describe('useMessageQueue', () => {
});
it('should filter out empty messages', () => {
- const { result } = renderHook(() =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- }),
- );
+ const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Valid message');
@@ -78,13 +56,7 @@ describe('useMessageQueue', () => {
});
it('should clear queue', () => {
- const { result } = renderHook(() =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- }),
- );
+ const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Test message');
@@ -100,13 +72,7 @@ describe('useMessageQueue', () => {
});
it('should return queued messages as text with double newlines', () => {
- const { result } = renderHook(() =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- }),
- );
+ const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Message 1');
@@ -119,181 +85,193 @@ describe('useMessageQueue', () => {
);
});
- it('should auto-submit queued messages when transitioning to Idle', () => {
- const { result, rerender } = renderHook(
- ({ streamingState }) =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState,
- submitQuery: mockSubmitQuery,
- }),
- {
- initialProps: { streamingState: StreamingState.Responding },
- },
- );
+ describe('popAllMessages (cancel and ESC/Up restore)', () => {
+ it('returns null when the queue is empty', () => {
+ const { result } = renderHook(() => useMessageQueue());
- // Add some messages
- act(() => {
- result.current.addMessage('Message 1');
- result.current.addMessage('Message 2');
+ let popped: string | null = null;
+ act(() => {
+ popped = result.current.popAllMessages();
+ });
+
+ expect(popped).toBeNull();
+ expect(result.current.messageQueue).toEqual([]);
});
- expect(result.current.messageQueue).toEqual(['Message 1', 'Message 2']);
+ it('joins all queued messages with double newlines and clears the queue', () => {
+ const { result } = renderHook(() => useMessageQueue());
- // Transition to Idle
- rerender({ streamingState: StreamingState.Idle });
+ act(() => {
+ result.current.addMessage('Message 1');
+ result.current.addMessage('Message 2');
+ result.current.addMessage('Message 3');
+ });
- expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2');
- expect(result.current.messageQueue).toEqual([]);
+ 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('returns a single message without separator', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ 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('joins mixed slash commands and prompts in original order', () => {
+ // Edit-restore intentionally collapses segment boundaries: the user is
+ // recovering input into the buffer to edit before resubmitting, so
+ // typing order matters more than slash-vs-prompt routing boundaries.
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('/model');
+ result.current.addMessage('hello');
+ result.current.addMessage('world');
+ });
+
+ let popped: string | null = null;
+ act(() => {
+ popped = result.current.popAllMessages();
+ });
+
+ expect(popped).toBe('/model\n\nhello\n\nworld');
+ expect(result.current.messageQueue).toEqual([]);
+ });
});
- it('should not auto-submit when queue is empty', () => {
- const { rerender } = renderHook(
- ({ streamingState }) =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState,
- submitQuery: mockSubmitQuery,
- }),
- {
- initialProps: { streamingState: StreamingState.Responding },
- },
- );
+ describe('drainQueue (mid-turn drain for tool-result injection)', () => {
+ it('returns an empty array when the queue is empty', () => {
+ const { result } = renderHook(() => useMessageQueue());
- // Transition to Idle with empty queue
- rerender({ streamingState: StreamingState.Idle });
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue();
+ });
+ expect(drained).toEqual([]);
+ });
- expect(mockSubmitQuery).not.toHaveBeenCalled();
+ it('drains all plain-text messages and leaves slash commands queued', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('one');
+ result.current.addMessage('two');
+ result.current.addMessage('/model');
+ result.current.addMessage('three');
+ });
+
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue();
+ });
+
+ expect(drained).toEqual(['one', 'two', 'three']);
+ expect(result.current.messageQueue).toEqual(['/model']);
+ });
+
+ it('returns an empty array when the queue contains only slash commands', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('/model');
+ result.current.addMessage('/help');
+ });
+
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue();
+ });
+
+ expect(drained).toEqual([]);
+ expect(result.current.messageQueue).toEqual(['/model', '/help']);
+ });
+
+ it('drains the whole queue when it contains no slash commands', () => {
+ const { result } = renderHook(() => useMessageQueue());
+
+ act(() => {
+ result.current.addMessage('a');
+ result.current.addMessage('b');
+ result.current.addMessage('c');
+ });
+
+ let drained: string[] = [];
+ act(() => {
+ drained = result.current.drainQueue();
+ });
+
+ expect(drained).toEqual(['a', 'b', 'c']);
+ expect(result.current.messageQueue).toEqual([]);
+ });
});
- it('should not auto-submit when not transitioning to Idle', () => {
- const { result, rerender } = renderHook(
- ({ streamingState }) =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState,
- submitQuery: mockSubmitQuery,
- }),
- {
- initialProps: { streamingState: StreamingState.Responding },
- },
- );
+ describe('popNextSegment', () => {
+ it('returns null when the queue is empty', () => {
+ const { result } = renderHook(() => useMessageQueue());
- // Add messages
- act(() => {
- result.current.addMessage('Message 1');
+ let segment: string | null = null;
+ act(() => {
+ segment = result.current.popNextSegment();
+ });
+ expect(segment).toBeNull();
});
- // Transition to WaitingForConfirmation (not Idle)
- rerender({ streamingState: StreamingState.WaitingForConfirmation });
+ it('pops the first item and leaves the rest queued', () => {
+ const { result } = renderHook(() => useMessageQueue());
- expect(mockSubmitQuery).not.toHaveBeenCalled();
- expect(result.current.messageQueue).toEqual(['Message 1']);
- });
+ act(() => {
+ result.current.addMessage('/model');
+ result.current.addMessage('/help');
+ });
- it('should handle multiple state transitions correctly', () => {
- const { result, rerender } = renderHook(
- ({ streamingState }) =>
- useMessageQueue({
- isConfigInitialized: true,
- streamingState,
- submitQuery: mockSubmitQuery,
- }),
- {
- initialProps: { streamingState: StreamingState.Idle },
- },
- );
-
- // Start responding
- rerender({ streamingState: StreamingState.Responding });
-
- // Add messages while responding
- act(() => {
- result.current.addMessage('First batch');
+ let segment: string | null = null;
+ act(() => {
+ segment = result.current.popNextSegment();
+ });
+ expect(segment).toBe('/model');
+ expect(result.current.messageQueue).toEqual(['/help']);
});
- // Go back to idle - should submit
- rerender({ streamingState: StreamingState.Idle });
+ it('drains the queue one item at a time across repeated calls', () => {
+ const { result } = renderHook(() => useMessageQueue());
- expect(mockSubmitQuery).toHaveBeenCalledWith('First batch');
- expect(result.current.messageQueue).toEqual([]);
+ act(() => {
+ result.current.addMessage('/model');
+ result.current.addMessage('/theme');
+ result.current.addMessage('/help');
+ });
- // Start responding again
- rerender({ streamingState: StreamingState.Responding });
+ const segments: Array = [];
+ act(() => {
+ segments.push(result.current.popNextSegment());
+ });
+ act(() => {
+ segments.push(result.current.popNextSegment());
+ });
+ act(() => {
+ segments.push(result.current.popNextSegment());
+ });
+ act(() => {
+ segments.push(result.current.popNextSegment());
+ });
- // Add more messages
- act(() => {
- result.current.addMessage('Second batch');
+ expect(segments).toEqual(['/model', '/theme', '/help', null]);
+ expect(result.current.messageQueue).toEqual([]);
});
-
- // Go back to idle - should submit again
- rerender({ streamingState: StreamingState.Idle });
-
- 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 cd1c8a294..348bfa070 100644
--- a/packages/cli/src/ui/hooks/useMessageQueue.ts
+++ b/packages/cli/src/ui/hooks/useMessageQueue.ts
@@ -4,45 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { StreamingState } from '../types.js';
-
-export interface UseMessageQueueOptions {
- isConfigInitialized: boolean;
- streamingState: StreamingState;
- submitQuery: (query: string) => void;
-}
+import { useCallback, useRef, useState } from 'react';
+import { isSlashCommand } from '../utils/commandUtils.js';
export interface UseMessageQueueReturn {
messageQueue: string[];
addMessage: (message: string) => void;
clearQueue: () => void;
getQueuedMessagesText: () => string;
+ /** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
popAllMessages: () => string | null;
- /**
- * Atomically drain all queued messages. Returns the drained messages
- * and clears both the synchronous ref and React state. Safe to call
- * from non-React contexts (e.g., tool completion callbacks).
- */
+ /** Drain plain-text prompts; leave slash commands queued. Safe from non-React callbacks. */
drainQueue: () => string[];
+ /** Pop the first item from the queue. */
+ popNextSegment: () => string | null;
}
-/**
- * Hook for managing message queuing during streaming responses.
- * Allows users to queue messages while the AI is responding and automatically
- * sends them when streaming completes.
- */
-export function useMessageQueue({
- isConfigInitialized,
- streamingState,
- submitQuery,
-}: UseMessageQueueOptions): UseMessageQueueReturn {
+export function useMessageQueue(): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState([]);
- // Synchronous ref mirrors React state so non-React callbacks (e.g.,
- // mid-turn drain in handleCompletedTools) always see the latest queue.
+ // Synchronous mirror so non-React callbacks see the latest queue.
const queueRef = useRef([]);
- // Add a message to the queue
const addMessage = useCallback((message: string) => {
const trimmedMessage = message.trim();
if (trimmedMessage.length > 0) {
@@ -51,58 +33,43 @@ export function useMessageQueue({
}
}, []);
- // Clear the entire queue
const clearQueue = useCallback(() => {
queueRef.current = [];
setMessageQueue([]);
}, []);
- // Get all queued messages as a single text string
const getQueuedMessagesText = useCallback(() => {
if (messageQueue.length === 0) return '';
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;
+ return current.join('\n\n');
}, []);
- // Atomically drain all queued messages (synchronous, safe from callbacks).
const drainQueue = useCallback((): string[] => {
- const drained = queueRef.current;
+ const current = queueRef.current;
+ if (current.length === 0) return [];
+ const drained = current.filter((message) => !isSlashCommand(message));
if (drained.length === 0) return [];
- queueRef.current = [];
- setMessageQueue([]);
+ const rest = current.filter((message) => isSlashCommand(message));
+ queueRef.current = rest;
+ setMessageQueue(rest);
return drained;
}, []);
- // Process queued messages when streaming becomes idle
- useEffect(() => {
- if (
- isConfigInitialized &&
- streamingState === StreamingState.Idle &&
- messageQueue.length > 0
- ) {
- // Combine all messages with double newlines for clarity
- const combinedMessage = messageQueue.join('\n\n');
- // Clear the queue and submit
- clearQueue();
- submitQuery(combinedMessage);
- }
- }, [
- isConfigInitialized,
- streamingState,
- messageQueue,
- submitQuery,
- clearQueue,
- ]);
+ const popNextSegment = useCallback((): string | null => {
+ const current = queueRef.current;
+ if (current.length === 0) return null;
+ const [head, ...rest] = current;
+ queueRef.current = rest;
+ setMessageQueue(rest);
+ return head;
+ }, []);
return {
messageQueue,
@@ -111,5 +78,6 @@ export function useMessageQueue({
getQueuedMessagesText,
popAllMessages,
drainQueue,
+ popNextSegment,
};
}