diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 284d8cae2..4a84e8a45 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1193,12 +1193,12 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: undefined as string | undefined, description: - 'Display mode for multi-agent sessions. "tmux" uses tmux panes, "iterm2" uses iTerm2 tabs, "in-process" runs in the current terminal.', + 'Display mode for multi-agent sessions. Currently only "in-process" is supported.', showInDialog: false, options: [ { value: 'in-process', label: 'In-process' }, - { value: 'tmux', label: 'tmux' }, - { value: 'iterm2', label: 'iTerm2' }, + // { value: 'tmux', label: 'tmux' }, + // { value: 'iterm2', label: 'iTerm2' }, ], }, arena: { diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 118308eaf..c178a021d 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -249,10 +249,7 @@ function executeArenaCommand( } else if (event.type === 'info') { addAndRecordArenaMessage(MessageType.INFO, event.message); } else { - addAndRecordArenaMessage( - MessageType.WARNING, - `Arena warning: ${event.message}`, - ); + addAndRecordArenaMessage(MessageType.WARNING, event.message); } }; diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index 3d8062bfa..d26d5db2f 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -18,9 +18,10 @@ */ import { Box, Text, useStdin } from 'ink'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AgentStatus, + isTerminalStatus, ApprovalMode, APPROVAL_MODES, } from '@qwen-code/qwen-code-core'; @@ -38,6 +39,7 @@ import { useTextBuffer } from '../shared/text-buffer.js'; import { calculatePromptWidths } from '../../utils/layoutUtils.js'; import { BaseTextInput } from '../BaseTextInput.js'; import { LoadingIndicator } from '../LoadingIndicator.js'; +import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js'; import { AgentFooter } from './AgentFooter.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; import { theme } from '../../semantic-colors.js'; @@ -182,13 +184,35 @@ export const AgentComposer: React.FC = ({ agentId }) => { [buffer, agentTabBarFocused, setAgentTabBarFocused], ); + // ── Message queue (accumulate while streaming, flush as one prompt on idle) ── + + const [messageQueue, setMessageQueue] = useState([]); + + // When agent becomes idle (and not terminal), flush queued messages. + useEffect(() => { + if ( + streamingState === StreamingState.Idle && + messageQueue.length > 0 && + status !== undefined && + !isTerminalStatus(status) + ) { + const combined = messageQueue.join('\n'); + setMessageQueue([]); + interactiveAgent?.enqueueMessage(combined); + } + }, [streamingState, messageQueue, interactiveAgent, status]); + const handleSubmit = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed || !interactiveAgent) return; - interactiveAgent.enqueueMessage(trimmed); + if (streamingState === StreamingState.Idle) { + interactiveAgent.enqueueMessage(trimmed); + } else { + setMessageQueue((prev) => [...prev, trimmed]); + } }, - [interactiveAgent], + [interactiveAgent, streamingState], ); // ── Render ── @@ -255,6 +279,8 @@ export const AgentComposer: React.FC = ({ agentId }) => { )} + + {/* Input prompt — always visible, like the main Composer */} item.agentId === agentId); const label = agent?.model.modelId || agentId; + pushMessage({ + messageType: 'info', + content: `Applying changes from ${label}…`, + }); const result = await mgr.applyAgentResult(agentId); if (!result.success) { pushMessage({ @@ -111,6 +115,10 @@ export function ArenaSelectDialog({ } try { + pushMessage({ + messageType: 'info', + content: 'Discarding Arena results and cleaning up…', + }); await config.cleanupArenaRuntime(true); pushMessage({ messageType: 'info', diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index a6409b793..e4a48031a 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -264,7 +264,11 @@ export function ArenaStatusDialog({ {failedToolCalls} ) : ( - + 0 ? theme.status.success : theme.text.primary + } + > {pad(String(toolCalls), colTools - 1, 'right')} )} diff --git a/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx index a790e20c2..65f363793 100644 --- a/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx @@ -80,9 +80,17 @@ export function ArenaStopDialog({ sessionStatus === ArenaSessionStatus.RUNNING || sessionStatus === ArenaSessionStatus.INITIALIZING ) { + pushMessage({ + messageType: 'info', + content: 'Stopping Arena agents…', + }); await mgr.cancel(); } await mgr.waitForSettled(); + pushMessage({ + messageType: 'info', + content: 'Cleaning up Arena resources…', + }); if (action === 'preserve') { await mgr.cleanupRuntime(); diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index e6e945bbd..b6b026a28 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -75,7 +75,7 @@ export const SuccessMessage: React.FC = ({ text }) => ( export const WarningMessage: React.FC = ({ text }) => ( diff --git a/packages/cli/src/ui/hooks/useAgentStreamingState.ts b/packages/cli/src/ui/hooks/useAgentStreamingState.ts index d53776242..881f715b2 100644 --- a/packages/cli/src/ui/hooks/useAgentStreamingState.ts +++ b/packages/cli/src/ui/hooks/useAgentStreamingState.ts @@ -124,7 +124,8 @@ export function useAgentStreamingState( }, [status, hasPendingApprovals]); const isInputActive = - streamingState === StreamingState.Idle && + (streamingState === StreamingState.Idle || + streamingState === StreamingState.Responding) && status !== undefined && !isTerminalStatus(status); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 427076666..6a386158f 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -1105,7 +1105,11 @@ export class ArenaManager { return incoming; } - private updateAgentStatus(agentId: string, newStatus: AgentStatus): void { + private updateAgentStatus( + agentId: string, + newStatus: AgentStatus, + options?: { roundCancelledByUser?: boolean }, + ): void { const agent = this.agents.get(agentId); if (!agent) { return; @@ -1130,7 +1134,11 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress(`Agent ${label} finished initial task.`, 'success'); + if (options?.roundCancelledByUser) { + this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning'); + } else { + this.emitProgress(`Agent ${label} finished initial task.`, 'success'); + } } // Emit progress messages for follow-up transitions (only after @@ -1145,7 +1153,14 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress(`Agent ${label} finished follow-up task.`, 'success'); + if (options?.roundCancelledByUser) { + this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning'); + } else { + this.emitProgress( + `Agent ${label} finished follow-up task.`, + 'success', + ); + } } } @@ -1317,7 +1332,10 @@ export class ArenaManager { agent.syncStats = syncStats; - const applyStatus = (incoming: AgentStatus) => { + const applyStatus = ( + incoming: AgentStatus, + options?: { roundCancelledByUser?: boolean }, + ) => { const resolved = this.resolveTransition(agent.status, incoming); if (!resolved) return; if (resolved === AgentStatus.FAILED) { @@ -1327,14 +1345,16 @@ export class ArenaManager { if (isSettledStatus(resolved)) { agent.stats.durationMs = Date.now() - agent.startedAt; } - this.updateAgentStatus(agent.agentId, resolved); + this.updateAgentStatus(agent.agentId, resolved, options); }; // Sync stats before mapping so counters are up-to-date even when // the provider omits usage_metadata events. const onStatusChange = (event: AgentStatusChangeEvent) => { syncStats(); - applyStatus(event.newStatus); + applyStatus(event.newStatus, { + roundCancelledByUser: event.roundCancelledByUser, + }); // Write status files so external consumers get a consistent // file-based view regardless of backend mode. this.flushInProcessStatusFiles().catch((err) => diff --git a/packages/core/src/agents/backends/detect.ts b/packages/core/src/agents/backends/detect.ts index c8c43c2c8..f94d8c41d 100644 --- a/packages/core/src/agents/backends/detect.ts +++ b/packages/core/src/agents/backends/detect.ts @@ -6,10 +6,10 @@ import { createDebugLogger } from '../../utils/debugLogger.js'; import type { Config } from '../../config/config.js'; -import { TmuxBackend } from './TmuxBackend.js'; +// import { TmuxBackend } from './TmuxBackend.js'; import { InProcessBackend } from './InProcessBackend.js'; import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js'; -import { isTmuxAvailable } from './tmux-commands.js'; +// import { isTmuxAvailable } from './tmux-commands.js'; const debugLogger = createDebugLogger('BACKEND_DETECT'); @@ -35,44 +35,54 @@ export async function detectBackend( preference: DisplayMode | undefined, runtimeContext: Config, ): Promise { - // 1. User explicit preference - if (preference === DISPLAY_MODE.IN_PROCESS) { - debugLogger.info('Using InProcessBackend (user preference)'); - return { backend: new InProcessBackend(runtimeContext) }; - } + // Currently only in-process mode is supported. Other backends (tmux, + // iterm2) are kept in the codebase but not wired up as entry points. + const warning = + preference && preference !== DISPLAY_MODE.IN_PROCESS + ? `Display mode "${preference}" is not currently supported. Using in-process mode instead.` + : undefined; + debugLogger.info('Using InProcessBackend'); + return { backend: new InProcessBackend(runtimeContext), warning }; - if (preference === DISPLAY_MODE.ITERM2) { - throw new Error( - `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, - ); - } - - if (preference === DISPLAY_MODE.TMUX) { - debugLogger.info('Using TmuxBackend (user preference)'); - return { backend: new TmuxBackend() }; - } - - // 2. Auto-detect - if (process.env['TMUX']) { - debugLogger.info('Detected $TMUX — attempting TmuxBackend'); - return { backend: new TmuxBackend() }; - } - - // Other terminals (including iTerm2): use tmux external session mode if available. - if (isTmuxAvailable()) { - debugLogger.info( - 'tmux is available — using TmuxBackend external session mode', - ); - return { backend: new TmuxBackend() }; - } - - // Fallback: use InProcessBackend - debugLogger.info( - 'No PTY backend available — falling back to InProcessBackend', - ); - return { - backend: new InProcessBackend(runtimeContext), - warning: - 'tmux is not available. Using in-process mode (no split-pane terminal view).', - }; + // --- Disabled backends (kept for future use) --- + // // 1. User explicit preference + // if (preference === DISPLAY_MODE.IN_PROCESS) { + // debugLogger.info('Using InProcessBackend (user preference)'); + // return { backend: new InProcessBackend(runtimeContext) }; + // } + // + // if (preference === DISPLAY_MODE.ITERM2) { + // throw new Error( + // `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, + // ); + // } + // + // if (preference === DISPLAY_MODE.TMUX) { + // debugLogger.info('Using TmuxBackend (user preference)'); + // return { backend: new TmuxBackend() }; + // } + // + // // 2. Auto-detect + // if (process.env['TMUX']) { + // debugLogger.info('Detected $TMUX — attempting TmuxBackend'); + // return { backend: new TmuxBackend() }; + // } + // + // // Other terminals (including iTerm2): use tmux external session mode if available. + // if (isTmuxAvailable()) { + // debugLogger.info( + // 'tmux is available — using TmuxBackend external session mode', + // ); + // return { backend: new TmuxBackend() }; + // } + // + // // Fallback: use InProcessBackend + // debugLogger.info( + // 'No PTY backend available — falling back to InProcessBackend', + // ); + // return { + // backend: new InProcessBackend(runtimeContext), + // warning: + // 'tmux is not available. Using in-process mode (no split-pane terminal view).', + // }; } diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index 643608681..4626bb0cd 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -176,6 +176,8 @@ export interface AgentStatusChangeEvent { agentId: string; previousStatus: AgentStatus; newStatus: AgentStatus; + /** True when the transition to IDLE was caused by user cancelling the round. */ + roundCancelledByUser?: boolean; timestamp: number; } diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 2683a6783..5560b665f 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -234,7 +234,7 @@ describe('AgentInteractive', () => { resolveLoop!(); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('failed'); + expect(agent.getStatus()).toBe('idle'); }); await agent.shutdown(); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index c7883f669..42e9dedce 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -25,9 +25,10 @@ import type { AgentCore } from './agent-core.js'; import type { ContextState } from './agent-headless.js'; import type { GeminiChat } from '../../core/geminiChat.js'; import type { FunctionDeclaration } from '@google/genai'; -import type { - ToolCallConfirmationDetails, - ToolResultDisplay, +import { + ToolConfirmationOutcome, + type ToolCallConfirmationDetails, + type ToolResultDisplay, } from '../../tools/tools.js'; import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; import { @@ -64,6 +65,7 @@ export class AgentInteractive { private chat: GeminiChat | undefined; private toolsList: FunctionDeclaration[] = []; private processing = false; + private roundCancelledByUser = false; // Pending tool approval requests. Keyed by callId. // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when @@ -161,6 +163,7 @@ export class AgentInteractive { this.setStatus(AgentStatus.RUNNING); this.lastRoundError = undefined; + this.roundCancelledByUser = false; this.roundAbortController = new AbortController(); // Propagate master abort to round @@ -199,6 +202,8 @@ export class AgentInteractive { this.lastRoundError = `Terminated: ${result.terminateMode}`; } } catch (err) { + // User-initiated cancellation already logged by cancelCurrentRound(). + if (this.roundCancelledByUser) return; // Agent survives round errors — log and settle status in runLoop. const errorMessage = err instanceof Error ? err.message : String(err); this.lastRoundError = errorMessage; @@ -220,6 +225,7 @@ export class AgentInteractive { * Adds a visible "cancelled" info message and clears pending approvals. */ cancelCurrentRound(): void { + this.roundCancelledByUser = true; this.roundAbortController?.abort(); this.pendingApprovals.clear(); this.addMessage('info', 'Agent round cancelled.', { @@ -344,7 +350,7 @@ export class AgentInteractive { * On error → FAILED (terminal). */ private settleRoundStatus(): void { - if (this.lastRoundError) { + if (this.lastRoundError && !this.roundCancelledByUser) { this.setStatus(AgentStatus.FAILED); } else { this.setStatus(AgentStatus.IDLE); @@ -361,6 +367,7 @@ export class AgentInteractive { agentId: this.config.agentId, previousStatus, newStatus, + roundCancelledByUser: this.roundCancelledByUser || undefined, timestamp: Date.now(), }); } @@ -462,6 +469,11 @@ export class AgentInteractive { timestamp: Date.now(), } as AgentToolOutputUpdateEvent); await event.respond(outcome, payload); + // When the user denies a tool, cancel the round immediately + // so the agent doesn't waste a turn "acknowledging" the denial. + if (outcome === ToolConfirmationOutcome.Cancel) { + this.cancelCurrentRound(); + } }, } as ToolCallConfirmationDetails;