From c87d2798bdb3e185e3263f842bd781d616624baf Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 24 Apr 2026 17:11:00 +0800 Subject: [PATCH] fix(cli): dispatch queued slash commands through the slash path (#3523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): dispatch queued slash commands through the slash path When the agent was responding and the user queued a message, the drain path joined all queued messages with `\n\n` and submitted them as one prompt. Any slash command in that blob (e.g. `/model`) no longer started with `/`, so it was sent to the model as plain text instead of opening the command's dialog. The mid-turn tool-result drain had the same problem: it drained the entire queue into the tool-result payload, so a slash command queued during tool execution was injected as context for the model rather than executed as a command. Queue draining now splits into segments — consecutive plain-text messages are still batched into one submission, while slash commands are submitted alone so their `/` prefix survives. The mid-turn drain only takes leading plain-text messages and leaves slash commands queued for the normal idle drain. The idle drain is gated on open dialogs so a queued `/model` does not cause the following queued prompt to be sent to the model while the picker is still open, and a re-entry lock plus a nonce close the race between state commits and the async dialog-open. * fix(cli): defer queued slash commands until idle * fix(cli): drop queued messages on cancel instead of auto-submitting Cancel's contract is now "abort and redirect" in both cancel paths: restore the most recent queued segment into the buffer for editing and drop the rest, so forgotten follow-ups cannot auto-submit once the turn settles. Previously the non-tool path left queued plain-text segments in place for the idle drain to fire, and the tool-executing path cleared only the buffer — both surprised users with belated message dispatches after they had already cancelled. * refactor(cli): batch plain prompts in idle drain Idle drain now runs in two phases: drain all plain-text prompts into one turn (drainQueue), then pop slash commands one-by-one (popNextSegment). Mirrors the mid-turn behavior so queue handling is consistent across mid-turn and idle contexts. popAllMessages now drains the entire queue joined with \n\n for Ctrl+C cancel and ESC/Up edit-restore. Drop the unused options parameter from useMessageQueue and the extractFirstSegment helper. --------- Co-authored-by: 愚远 --- packages/cli/src/ui/AppContainer.test.tsx | 117 +++++- packages/cli/src/ui/AppContainer.tsx | 75 +++- .../cli/src/ui/hooks/useMessageQueue.test.ts | 364 ++++++++---------- packages/cli/src/ui/hooks/useMessageQueue.ts | 80 ++-- 4 files changed, 367 insertions(+), 269 deletions(-) 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, }; }