diff --git a/.changeset/brown-pans-kneel.md b/.changeset/brown-pans-kneel.md new file mode 100644 index 00000000000..578df1bff73 --- /dev/null +++ b/.changeset/brown-pans-kneel.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Continue and abort commands diff --git a/cli/src/services/approvalDecision.ts b/cli/src/services/approvalDecision.ts index ceab5ce17f7..e368678a61d 100644 --- a/cli/src/services/approvalDecision.ts +++ b/cli/src/services/approvalDecision.ts @@ -334,6 +334,11 @@ export function getApprovalDecision( case "command": return getCommandApprovalDecision(message, config, isCIMode) + case "command_output": + // Command output always requires manual approval + // User must choose to continue (proceed with conversation) or abort (kill command) + return { action: "manual" } + case "followup": return getFollowupApprovalDecision(message, config, isCIMode) diff --git a/cli/src/state/atoms/__tests__/effects-command-output-duplicate.test.ts b/cli/src/state/atoms/__tests__/effects-command-output-duplicate.test.ts new file mode 100644 index 00000000000..b7209770676 --- /dev/null +++ b/cli/src/state/atoms/__tests__/effects-command-output-duplicate.test.ts @@ -0,0 +1,203 @@ +/** + * Tests for command_output ask deduplication + * Verifies that duplicate asks from the backend are properly filtered + * when the CLI has already created a synthetic ask + */ + +import { describe, it, expect, beforeEach } from "vitest" +import { createStore } from "jotai" +import { messageHandlerEffectAtom, commandOutputAskShownAtom } from "../effects.js" +import { chatMessagesAtom } from "../extension.js" +import { extensionServiceAtom } from "../service.js" +import type { ExtensionService } from "../../../services/extension.js" +import type { ExtensionMessage, ExtensionChatMessage, ExtensionState } from "../../../types/messages.js" + +describe("Command Output Ask Deduplication", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + + // Mock the extension service + const mockService: Partial = { + initialize: async () => {}, + on: () => mockService as ExtensionService, + getState: () => null, + } + store.set(extensionServiceAtom, mockService as ExtensionService) + }) + + it("should filter duplicate command_output ask from state when synthetic ask exists", () => { + const executionId = "test-exec-123" + + // Step 1: Simulate commandExecutionStatus "started" which creates synthetic ask + const startedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "started", + command: "sleep 30", + }), + } + store.set(messageHandlerEffectAtom, startedMessage) + + // Verify synthetic ask was created + const messagesAfterStart = store.get(chatMessagesAtom) + expect(messagesAfterStart).toHaveLength(1) + expect(messagesAfterStart[0]?.ask).toBe("command_output") + + // Step 2: Simulate backend sending its own command_output ask via state + const backendAsk: ExtensionChatMessage = { + ts: Date.now() + 100, + type: "ask", + ask: "command_output", + text: JSON.stringify({ + executionId, + command: "sleep 30", + output: "", + }), + partial: true, + isAnswered: false, + } + + const stateMessage: ExtensionMessage = { + type: "state", + state: { + chatMessages: [messagesAfterStart[0]!, backendAsk], + } as unknown as ExtensionState, + } + store.set(messageHandlerEffectAtom, stateMessage) + + // Verify duplicate was filtered - should still have only 1 message + const messagesAfterState = store.get(chatMessagesAtom) + expect(messagesAfterState).toHaveLength(1) + expect(messagesAfterState[0]?.ts).toBe(messagesAfterStart[0]?.ts) + }) + + it("should filter duplicate command_output ask from messageUpdated when synthetic ask exists", () => { + const executionId = "test-exec-456" + + // Step 1: Create synthetic ask + const startedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "started", + command: "sleep 30", + }), + } + store.set(messageHandlerEffectAtom, startedMessage) + + const messagesAfterStart = store.get(chatMessagesAtom) + expect(messagesAfterStart).toHaveLength(1) + + // Step 2: Simulate backend sending its own ask via messageUpdated + const backendAsk: ExtensionChatMessage = { + ts: Date.now() + 100, + type: "ask", + ask: "command_output", + text: JSON.stringify({ + executionId, + command: "sleep 30", + output: "", + }), + partial: true, + isAnswered: false, + } + + const messageUpdatedMessage: ExtensionMessage = { + type: "messageUpdated", + chatMessage: backendAsk, + } + store.set(messageHandlerEffectAtom, messageUpdatedMessage) + + // Verify duplicate was ignored - should still have only 1 message + const messagesAfterUpdate = store.get(chatMessagesAtom) + expect(messagesAfterUpdate).toHaveLength(1) + expect(messagesAfterUpdate[0]?.ts).toBe(messagesAfterStart[0]?.ts) + }) + + it("should allow backend ask when no synthetic ask exists", () => { + const executionId = "test-exec-789" + + // This test verifies that our filtering logic doesn't break normal scenarios + // where the backend creates a command_output ask without a prior synthetic one + + // In this case, we DON'T create a synthetic ask first + // Instead, we simulate the backend creating its own ask + // This would happen if the command produces output immediately (before our synthetic ask is created) + + // Since we can't easily test the full state update flow in a unit test, + // we'll just verify that the tracking map doesn't have this executionId + // which means our filter won't block it + + const askShownMap = store.get(commandOutputAskShownAtom) + expect(askShownMap.has(executionId)).toBe(false) + + // This means when a backend ask with this executionId comes through, + // it won't be filtered out by our duplicate detection logic + }) + + it("should update synthetic ask with output when command produces output", () => { + const executionId = "test-exec-output" + + // Step 1: Create synthetic ask + const startedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "started", + command: "echo test", + }), + } + store.set(messageHandlerEffectAtom, startedMessage) + + // Step 2: Send output + const outputMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "output", + output: "test\n", + }), + } + store.set(messageHandlerEffectAtom, outputMessage) + + // Verify synthetic ask was updated with output + const messages = store.get(chatMessagesAtom) + expect(messages).toHaveLength(1) + + const askData = JSON.parse(messages[0]?.text || "{}") + expect(askData.output).toBe("test\n") + expect(askData.command).toBe("echo test") + }) + + it("should mark synthetic ask as complete when command exits", () => { + const executionId = "test-exec-complete" + + // Step 1: Create synthetic ask + store.set(messageHandlerEffectAtom, { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "started", + command: "sleep 1", + }), + }) + + // Step 2: Command exits + store.set(messageHandlerEffectAtom, { + type: "commandExecutionStatus", + text: JSON.stringify({ + executionId, + status: "exited", + exitCode: 0, + }), + }) + + // Verify synthetic ask is marked as complete + const messages = store.get(chatMessagesAtom) + expect(messages).toHaveLength(1) + expect(messages[0]?.partial).toBe(false) + }) +}) diff --git a/cli/src/state/atoms/__tests__/effects-command-output.test.ts b/cli/src/state/atoms/__tests__/effects-command-output.test.ts new file mode 100644 index 00000000000..d6a3a70b95d --- /dev/null +++ b/cli/src/state/atoms/__tests__/effects-command-output.test.ts @@ -0,0 +1,253 @@ +/** + * Tests for command execution status handling in effects.ts + * Specifically tests the CLI-only workaround for commands that produce no output (like `sleep 10`) + */ + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { createStore } from "jotai" +import { messageHandlerEffectAtom, pendingOutputUpdatesAtom } from "../effects.js" +import { extensionServiceAtom } from "../service.js" +import { chatMessagesAtom } from "../extension.js" +import type { ExtensionMessage } from "../../../types/messages.js" +import type { CommandExecutionStatus } from "@roo-code/types" +import type { ExtensionService } from "../../../services/extension.js" + +describe("Command Execution Status - CLI-Only Workaround", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + + // Mock the extension service to prevent buffering + const mockService: Partial = { + initialize: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } + store.set(extensionServiceAtom, mockService as ExtensionService) + }) + + it("should synthesize command_output ask immediately on start and update on exit", () => { + const executionId = "test-exec-123" + const command = "sleep 10" + + // Simulate command started + const startedStatus: CommandExecutionStatus = { + status: "started", + executionId, + command, + } + + const startedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify(startedStatus), + } + + store.set(messageHandlerEffectAtom, startedMessage) + + // Verify pending updates were created with command info + let pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.has(executionId)).toBe(true) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "", + command: "sleep 10", + }) + + // Verify synthetic command_output ask was created IMMEDIATELY + let messages = store.get(chatMessagesAtom) + expect(messages.length).toBe(1) + expect(messages[0]).toMatchObject({ + type: "ask", + ask: "command_output", + partial: true, // Still running + isAnswered: false, + }) + + // Verify the synthetic ask has the correct initial data + const askData = JSON.parse(messages[0]!.text || "{}") + expect(askData).toEqual({ + executionId: "test-exec-123", + command: "sleep 10", + output: "", + }) + + // Simulate command exited without any output + const exitedStatus: CommandExecutionStatus = { + status: "exited", + executionId, + exitCode: 0, + } + + const exitedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify(exitedStatus), + } + + store.set(messageHandlerEffectAtom, exitedMessage) + + // Verify command info is preserved and marked as completed + pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.has(executionId)).toBe(true) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "", + command: "sleep 10", + completed: true, + }) + + // Verify the ask was updated to mark as complete (not partial) + messages = store.get(chatMessagesAtom) + expect(messages.length).toBe(1) // Still just one message + expect(messages[0]).toMatchObject({ + type: "ask", + ask: "command_output", + partial: false, // Now complete + isAnswered: false, + }) + }) + + it("should handle commands with output (started -> output -> exited)", () => { + const executionId = "test-exec-456" + const command = "echo hello" + + // Simulate command started + const startedStatus: CommandExecutionStatus = { + status: "started", + executionId, + command, + } + + const startedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify(startedStatus), + } + + store.set(messageHandlerEffectAtom, startedMessage) + + // Verify initial state + let pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "", + command: "echo hello", + }) + + // Verify synthetic ask was created on start + let messages = store.get(chatMessagesAtom) + expect(messages.length).toBe(1) + expect(messages[0]?.partial).toBe(true) + + // Simulate output received + const outputStatus: CommandExecutionStatus = { + status: "output", + executionId, + output: "hello\n", + } + + const outputMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify(outputStatus), + } + + store.set(messageHandlerEffectAtom, outputMessage) + + // Verify output was updated + pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "hello\n", + command: "echo hello", + }) + + // Verify the synthetic ask was updated with output + messages = store.get(chatMessagesAtom) + expect(messages.length).toBe(1) + const askData = JSON.parse(messages[0]!.text || "{}") + expect(askData.output).toBe("hello\n") + expect(messages[0]?.partial).toBe(true) // Still running + + // Simulate command exited + const exitedStatus: CommandExecutionStatus = { + status: "exited", + executionId, + exitCode: 0, + } + + const exitedMessage: ExtensionMessage = { + type: "commandExecutionStatus", + text: JSON.stringify(exitedStatus), + } + + store.set(messageHandlerEffectAtom, exitedMessage) + + // Verify final state + pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "hello\n", + command: "echo hello", + completed: true, + }) + + // Verify the ask was marked as complete + messages = store.get(chatMessagesAtom) + expect(messages.length).toBe(1) + expect(messages[0]?.partial).toBe(false) // Now complete + }) + + it("should handle timeout status", () => { + const executionId = "test-exec-789" + const command = "sleep 1000" + + // Simulate command started + const startedStatus: CommandExecutionStatus = { + status: "started", + executionId, + command, + } + + store.set(messageHandlerEffectAtom, { + type: "commandExecutionStatus", + text: JSON.stringify(startedStatus), + }) + + // Simulate timeout + const timeoutStatus: CommandExecutionStatus = { + status: "timeout", + executionId, + } + + store.set(messageHandlerEffectAtom, { + type: "commandExecutionStatus", + text: JSON.stringify(timeoutStatus), + }) + + // Verify command info is preserved and marked as completed + const pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "", + command: "sleep 1000", + completed: true, + }) + }) + + it("should handle empty command in started status", () => { + const executionId = "test-exec-no-cmd" + + // Simulate command started with empty command field + const startedStatus: CommandExecutionStatus = { + status: "started", + executionId, + command: "", + } + + store.set(messageHandlerEffectAtom, { + type: "commandExecutionStatus", + text: JSON.stringify(startedStatus), + }) + + // Verify it still creates an entry with empty command + const pendingUpdates = store.get(pendingOutputUpdatesAtom) + expect(pendingUpdates.get(executionId)).toEqual({ + output: "", + command: "", + }) + }) +}) diff --git a/cli/src/state/atoms/approval.ts b/cli/src/state/atoms/approval.ts index aeb3111dda4..d9048cbab2b 100644 --- a/cli/src/state/atoms/approval.ts +++ b/cli/src/state/atoms/approval.ts @@ -128,7 +128,23 @@ export const approvalOptionsAtom = atom((get) => { let approveLabel = "Approve" let rejectLabel = "Reject" - if (pendingMessage.ask === "checkpoint_restore") { + if (pendingMessage.ask === "command_output") { + // Special handling for command output - Continue/Abort + return [ + { + label: "Continue", + action: "approve" as const, + hotkey: "y", + color: "green" as const, + }, + { + label: "Abort", + action: "reject" as const, + hotkey: "n", + color: "red" as const, + }, + ] + } else if (pendingMessage.ask === "checkpoint_restore") { approveLabel = "Restore Checkpoint" rejectLabel = "Cancel" } else if (pendingMessage.ask === "tool") { @@ -242,7 +258,10 @@ export const setPendingApprovalAtom = atom(null, (get, set, message: ExtensionCh } // Reset selection if this is a new message (different timestamp) - if (isNewMessage) { + // EXCEPT for command_output messages where we want to preserve selection + // as output streams in (to allow users to abort long-running commands) + const shouldResetSelection = isNewMessage && message?.ask !== "command_output" + if (shouldResetSelection) { set(selectedIndexAtom, 0) } }) @@ -361,6 +380,12 @@ export const rejectCallbackAtom = atom<(() => Promise) | null>(null) */ export const executeSelectedCallbackAtom = atom<(() => Promise) | null>(null) +/** + * Atom to store the sendTerminalOperation callback + * The hook sets this to its sendTerminalOperation function + */ +export const sendTerminalOperationCallbackAtom = atom<((operation: "continue" | "abort") => Promise) | null>(null) + /** * Action atom to approve the pending request * Calls the callback set by the hook @@ -393,3 +418,14 @@ export const executeSelectedAtom = atom(null, async (get, _set) => { await callback() } }) + +/** + * Action atom to send terminal operation (continue or abort) + * Calls the callback set by the hook + */ +export const sendTerminalOperationAtom = atom(null, async (get, _set, operation: "continue" | "abort") => { + const callback = get(sendTerminalOperationCallbackAtom) + if (callback) { + await callback(operation) + } +}) diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index fc8af890b03..8cde76eef4f 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -5,9 +5,15 @@ import { atom } from "jotai" import type { ExtensionMessage, ExtensionChatMessage, RouterModels } from "../../types/messages.js" -import type { HistoryItem } from "@roo-code/types" +import type { HistoryItem, CommandExecutionStatus } from "@roo-code/types" import { extensionServiceAtom, setServiceReadyAtom, setServiceErrorAtom, setIsInitializingAtom } from "./service.js" -import { updateExtensionStateAtom, updateChatMessageByTsAtom, updateRouterModelsAtom } from "./extension.js" +import { + updateExtensionStateAtom, + updateChatMessageByTsAtom, + updateRouterModelsAtom, + chatMessagesAtom, + updateChatMessagesAtom, +} from "./extension.js" import { ciCompletionDetectedAtom } from "./ci.js" import { updateProfileDataAtom, @@ -38,6 +44,22 @@ const messageBufferAtom = atom([]) */ const isProcessingBufferAtom = atom(false) +/** + * Map to store pending output updates for command_output asks + * Key: executionId, Value: latest output data + * Exported so extension.ts can apply pending updates when asks appear + */ +export const pendingOutputUpdatesAtom = atom>( + new Map(), +) + +/** + * Map to track which commands have shown a command_output ask + * Key: executionId, Value: true if ask was shown + * Exported for testing + */ +export const commandOutputAskShownAtom = atom>(new Map()) + // Indexing status types export interface IndexingStatus { systemStatus: string @@ -154,11 +176,107 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension case "state": // State messages are handled by the stateChange event listener // Skip processing here to avoid duplication + + // Track command_output asks that appear in state updates + // Also filter out duplicate asks that conflict with our synthetic ones + if (message.state?.chatMessages) { + const askShownMap = get(commandOutputAskShownAtom) + const newAskShownMap = new Map(askShownMap) + const filteredMessages: typeof message.state.chatMessages = [] + + for (const msg of message.state.chatMessages) { + if (msg.type === "ask" && msg.ask === "command_output" && msg.text) { + try { + const data = JSON.parse(msg.text) + const executionId = data.executionId + + if (executionId) { + // Check if we already have a synthetic ask for this execution + if (askShownMap.has(executionId) && !msg.isAnswered) { + // Skip this message - we already have a synthetic ask + continue + } + + // Track this ask + newAskShownMap.set(executionId, true) + } + } catch { + // Ignore parse errors + } + } + + filteredMessages.push(msg) + } + + // Update the state with filtered messages + if (filteredMessages.length !== message.state.chatMessages.length) { + message.state.chatMessages = filteredMessages + } + + if (newAskShownMap.size !== askShownMap.size) { + set(commandOutputAskShownAtom, newAskShownMap) + } + } break case "messageUpdated": { const chatMessage = message.chatMessage as ExtensionChatMessage | undefined if (chatMessage) { + // Special handling for command_output asks to prevent conflicts + if (chatMessage.type === "ask" && chatMessage.ask === "command_output") { + logs.info( + `[messageUpdated] Received command_output ask, ts: ${chatMessage.ts}, isAnswered: ${chatMessage.isAnswered}`, + "effects", + ) + + // Check if we already have a synthetic ask for this execution + const currentMessages = get(chatMessagesAtom) + const askShownMap = get(commandOutputAskShownAtom) + + logs.info( + `[messageUpdated] Current tracking map has ${askShownMap.size} entries, current messages: ${currentMessages.length}`, + "effects", + ) + + // Try to extract executionId from the incoming message + let incomingExecutionId: string | undefined + try { + if (chatMessage.text) { + const data = JSON.parse(chatMessage.text) + incomingExecutionId = data.executionId + logs.info(`[messageUpdated] Extracted executionId: ${incomingExecutionId}`, "effects") + } + } catch (error) { + logs.warn(`[messageUpdated] Failed to parse command_output ask text: ${error}`, "effects") + } + + // Check if we already have a synthetic ask for this executionId + const hasSyntheticAsk = incomingExecutionId && askShownMap.has(incomingExecutionId) + + if (hasSyntheticAsk) { + // We already have a synthetic ask for this execution + // The backend is trying to create its own ask, but we should ignore it + // since our synthetic ask is already handling user interaction + logs.info( + `[messageUpdated] IGNORING duplicate command_output ask for executionId: ${incomingExecutionId}`, + "effects", + ) + // Don't update the message - keep our synthetic one + break + } + + // Track this ask if it has an executionId + if (incomingExecutionId) { + logs.info( + `[messageUpdated] Tracking new command_output ask for executionId: ${incomingExecutionId}`, + "effects", + ) + const newAskShownMap = new Map(askShownMap) + newAskShownMap.set(incomingExecutionId, true) + set(commandOutputAskShownAtom, newAskShownMap) + } + } + set(updateChatMessageByTsAtom, chatMessage) } break @@ -252,6 +370,204 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension break } + case "commandExecutionStatus": { + // Handle command execution status messages + // Store output updates and apply them when the ask appears + try { + const statusData = JSON.parse(message.text || "{}") as CommandExecutionStatus + const pendingUpdates = get(pendingOutputUpdatesAtom) + const newPendingUpdates = new Map(pendingUpdates) + + if (statusData.status === "started") { + logs.info( + `[commandExecutionStatus] Command started: ${statusData.executionId}, command: ${statusData.command}`, + "effects", + ) + + // Initialize with command info + // IMPORTANT: Store the command immediately so it's available even if no output is produced + const command = "command" in statusData ? (statusData.command as string) : undefined + const updateData: { output: string; command?: string; completed?: boolean } = { + output: "", + command: command || "", // Always set command, even if empty + } + newPendingUpdates.set(statusData.executionId, updateData) + + // CLI-ONLY WORKAROUND: Immediately create a synthetic command_output ask + // This allows users to abort the command even before any output is produced + const syntheticAsk: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "command_output", + text: JSON.stringify({ + executionId: statusData.executionId, + command: command || "", + output: "", + }), + partial: true, // Mark as partial since command is still running + isAnswered: false, + } + + // Add the synthetic message to chat messages + const currentMessages = get(chatMessagesAtom) + set(updateChatMessagesAtom, [...currentMessages, syntheticAsk]) + + logs.info( + `[commandExecutionStatus] Created synthetic ask for ${statusData.executionId}, ts: ${syntheticAsk.ts}`, + "effects", + ) + + // Mark that we've shown an ask for this execution + const askShownMap = get(commandOutputAskShownAtom) + const newAskShownMap = new Map(askShownMap) + newAskShownMap.set(statusData.executionId, true) + set(commandOutputAskShownAtom, newAskShownMap) + + logs.info( + `[commandExecutionStatus] Tracking map now has ${newAskShownMap.size} entries`, + "effects", + ) + } else if (statusData.status === "output") { + logs.debug( + `[commandExecutionStatus] Output received for ${statusData.executionId}, length: ${statusData.output?.length || 0}`, + "effects", + ) + + // Update with new output + const existing = newPendingUpdates.get(statusData.executionId) || { output: "" } + const command = "command" in statusData ? (statusData.command as string) : existing.command + const updateData: { output: string; command?: string; completed?: boolean } = { + output: statusData.output || "", + } + if (command) { + updateData.command = command + } + if (existing.completed !== undefined) { + updateData.completed = existing.completed + } + newPendingUpdates.set(statusData.executionId, updateData) + + // Update the synthetic ask with the new output + // Find and update the synthetic message we created + const currentMessages = get(chatMessagesAtom) + const messageIndex = currentMessages.findIndex((msg) => { + if (msg.type === "ask" && msg.ask === "command_output" && msg.text) { + try { + const data = JSON.parse(msg.text) + return data.executionId === statusData.executionId + } catch { + return false + } + } + return false + }) + + if (messageIndex !== -1) { + const updatedAsk: ExtensionChatMessage = { + ...currentMessages[messageIndex]!, + text: JSON.stringify({ + executionId: statusData.executionId, + command: command || "", + output: statusData.output || "", + }), + partial: true, // Still running + } + + const newMessages = [...currentMessages] + newMessages[messageIndex] = updatedAsk + set(updateChatMessagesAtom, newMessages) + + logs.debug( + `[commandExecutionStatus] Updated synthetic ask at index ${messageIndex}`, + "effects", + ) + } else { + logs.warn( + `[commandExecutionStatus] Could not find synthetic ask for ${statusData.executionId}`, + "effects", + ) + } + } else if (statusData.status === "exited" || statusData.status === "timeout") { + const exitCodeInfo = + statusData.status === "exited" && "exitCode" in statusData + ? `, exitCode: ${statusData.exitCode}` + : "" + logs.info( + `[commandExecutionStatus] Command ${statusData.status} for ${statusData.executionId}${exitCodeInfo}`, + "effects", + ) + + // Mark as completed and ensure command is preserved + const existing = newPendingUpdates.get(statusData.executionId) || { output: "", command: "" } + // If command wasn't set yet (shouldn't happen but defensive), try to get it from statusData + const command = + existing.command || ("command" in statusData ? (statusData.command as string) : "") + const finalUpdate = { + ...existing, + command: command, + completed: true, + } + newPendingUpdates.set(statusData.executionId, finalUpdate) + } + + set(pendingOutputUpdatesAtom, newPendingUpdates) + + // CLI-ONLY WORKAROUND: Mark synthetic ask as complete when command exits + if (statusData.status === "exited" || statusData.status === "timeout") { + // Find and update the synthetic ask to mark it as complete + const currentMessages = get(chatMessagesAtom) + + logs.info( + `[commandExecutionStatus] Looking for synthetic ask among ${currentMessages.length} messages`, + "effects", + ) + + const messageIndex = currentMessages.findIndex((msg) => { + if (msg.type === "ask" && msg.ask === "command_output" && msg.text) { + try { + const data = JSON.parse(msg.text) + return data.executionId === statusData.executionId + } catch { + return false + } + } + return false + }) + + if (messageIndex !== -1) { + const pendingUpdate = newPendingUpdates.get(statusData.executionId) + const updatedAsk: ExtensionChatMessage = { + ...currentMessages[messageIndex]!, + text: JSON.stringify({ + executionId: statusData.executionId, + command: pendingUpdate?.command || "", + output: pendingUpdate?.output || "", + }), + partial: false, // Command completed + isAnswered: false, // Still needs user response + } + + const newMessages = [...currentMessages] + newMessages[messageIndex] = updatedAsk + set(updateChatMessagesAtom, newMessages) + + logs.info( + `[commandExecutionStatus] Marked synthetic ask as complete at index ${messageIndex}`, + "effects", + ) + } else { + logs.warn( + `[commandExecutionStatus] Could not find synthetic ask to mark complete for ${statusData.executionId}`, + "effects", + ) + } + } + } catch (error) { + logs.error("Error handling commandExecutionStatus", "effects", { error }) + } + break + } + default: logs.debug(`Unhandled message type: ${message.type}`, "effects") } @@ -321,6 +637,12 @@ export const disposeServiceEffectAtom = atom(null, async (get, set) => { // Clear any buffered messages set(messageBufferAtom, []) + // Clear pending output updates + set(pendingOutputUpdatesAtom, new Map()) + + // Clear command output ask tracking + set(commandOutputAskShownAtom, new Map()) + // Dispose the service await service.dispose() diff --git a/cli/src/state/atoms/extension.ts b/cli/src/state/atoms/extension.ts index 45bb8202400..26c7c1f19b1 100644 --- a/cli/src/state/atoms/extension.ts +++ b/cli/src/state/atoms/extension.ts @@ -12,6 +12,7 @@ import type { ProviderSettings, McpServer, } from "../../types/messages.js" +import { pendingOutputUpdatesAtom } from "./effects.js" /** * Atom to hold the complete ExtensionState @@ -201,6 +202,7 @@ export const updateExtensionStateAtom = atom(null, (get, set, state: ExtensionSt const currentMessages = get(chatMessagesAtom) const versionMap = get(messageVersionMapAtom) const streamingSet = get(streamingMessagesSetAtom) + const pendingUpdates = get(pendingOutputUpdatesAtom) set(extensionStateAtom, state) @@ -209,7 +211,14 @@ export const updateExtensionStateAtom = atom(null, (get, set, state: ExtensionSt const incomingMessages = state.clineMessages || state.chatMessages || [] // Reconcile with current messages to preserve streaming state - let reconciledMessages = reconcileMessages(currentMessages, incomingMessages, versionMap, streamingSet) + // Pass pending updates to apply them to new command_output asks + let reconciledMessages = reconcileMessages( + currentMessages, + incomingMessages, + versionMap, + streamingSet, + pendingUpdates, + ) // Auto-complete orphaned partial ask messages (CLI-only workaround for extension bug) reconciledMessages = autoCompleteOrphanedPartialAsks(reconciledMessages) @@ -528,12 +537,14 @@ function autoCompleteOrphanedPartialAsks(messages: ExtensionChatMessage[]): Exte * - State is the source of truth for WHICH messages exist (count/list) * - Real-time updates are the source of truth for CONTENT of partial messages * - Only preserve content of actively streaming messages if they have more data + * - Merge duplicate command_output asks with the same executionId */ function reconcileMessages( current: ExtensionChatMessage[], incoming: ExtensionChatMessage[], versionMap: Map, streamingSet: Set, + pendingUpdates?: Map, ): ExtensionChatMessage[] { // Create lookup map for current messages const currentMap = new Map() @@ -541,8 +552,33 @@ function reconcileMessages( currentMap.set(msg.ts, msg) }) + // Identify synthetic command_output asks (CLI-created, not from extension) + // These have executionId in their text and don't exist in incoming messages + const syntheticAsks: ExtensionChatMessage[] = [] + current.forEach((msg) => { + if (msg.type === "ask" && msg.ask === "command_output" && msg.text) { + try { + const data = JSON.parse(msg.text) + if (data.executionId) { + // Check if this message exists in incoming + const existsInIncoming = incoming.some((incomingMsg) => incomingMsg.ts === msg.ts) + if (!existsInIncoming) { + // This is a synthetic ask created by CLI + syntheticAsks.push(msg) + } + } + } catch { + // Ignore parse errors + } + } + }) + + // First, deduplicate command_output asks + // Since extension creates asks with empty text, we keep only the first unanswered one + const deduplicatedIncoming = deduplicateCommandOutputAsks(incoming, pendingUpdates) + // Process ALL incoming messages - state determines which messages exist - const resultMessages: ExtensionChatMessage[] = incoming.map((incomingMsg) => { + const resultMessages: ExtensionChatMessage[] = deduplicatedIncoming.map((incomingMsg) => { const existingMsg = currentMap.get(incomingMsg.ts) // PRIORITY 1: Prevent completed messages from being overwritten by stale partial updates @@ -586,6 +622,91 @@ function reconcileMessages( return incomingMsg }) + // Add synthetic asks back to the result + // These are CLI-created asks that the extension doesn't know about + const finalMessages = [...resultMessages, ...syntheticAsks] + // Return sorted array by timestamp - return resultMessages.sort((a, b) => a.ts - b.ts) + return finalMessages.sort((a, b) => a.ts - b.ts) +} + +/** + * Deduplicate command_output asks + * Since the extension creates asks with empty text (no executionId), we can't match by executionId + * Instead, keep only the MOST RECENT unanswered command_output ask and discard older ones + * This allows multiple sequential commands to work correctly + * The component will read from pendingOutputUpdatesAtom for real-time output + */ +function deduplicateCommandOutputAsks( + messages: ExtensionChatMessage[], + pendingUpdates?: Map, +): ExtensionChatMessage[] { + const result: ExtensionChatMessage[] = [] + let mostRecentUnansweredAsk: ExtensionChatMessage | null = null + let mostRecentUnansweredAskIndex = -1 + + // First pass: find the most recent unanswered command_output ask + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg && msg.type === "ask" && msg.ask === "command_output" && !msg.isAnswered) { + if (!mostRecentUnansweredAsk || msg.ts > mostRecentUnansweredAsk.ts) { + mostRecentUnansweredAsk = msg + mostRecentUnansweredAskIndex = i + } + } + } + + // Second pass: build result, keeping only the most recent unanswered ask + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + if (msg && msg.type === "ask" && msg.ask === "command_output" && !msg.isAnswered) { + if (i === mostRecentUnansweredAskIndex) { + // This is the most recent unanswered ask - keep it with pending updates + let processedMsg = msg + + // If we have pending updates, find the one that's NOT completed (the active command) + if (pendingUpdates && pendingUpdates.size > 0) { + // Find the active (non-completed) pending update + let activeExecutionId: string | null = null + let activeUpdate: { output: string; command?: string; completed?: boolean } | null = null + + for (const [execId, update] of pendingUpdates.entries()) { + if (!update.completed) { + activeExecutionId = execId + activeUpdate = update + break + } + } + + // If no active update found, use the most recent one (fallback) + if (!activeExecutionId && pendingUpdates.size > 0) { + const entries = Array.from(pendingUpdates.entries()) + ;[activeExecutionId, activeUpdate] = entries[entries.length - 1]! + } + + if (activeExecutionId && activeUpdate) { + processedMsg = { + ...msg, + text: JSON.stringify({ + executionId: activeExecutionId, + command: activeUpdate.command || "", + output: activeUpdate.output || "", + }), + partial: !activeUpdate.completed, + isAnswered: activeUpdate.completed || false, + } + } + } + + result.push(processedMsg) + } + // Discard older unanswered command_output asks (no logging needed) + } else if (msg) { + // Not an unanswered command_output ask, keep as-is + result.push(msg) + } + } + + return result } diff --git a/cli/src/state/atoms/ui.ts b/cli/src/state/atoms/ui.ts index 59cd4dc734c..3cbf3ff1288 100644 --- a/cli/src/state/atoms/ui.ts +++ b/cli/src/state/atoms/ui.ts @@ -290,6 +290,7 @@ export const lastAskMessageAtom = atom((get) => { const approvalAskTypes = [ "tool", "command", + "command_output", "browser_action_launch", "use_mcp_server", "payment_required_prompt", @@ -297,15 +298,19 @@ export const lastAskMessageAtom = atom((get) => { ] const lastMessage = messages[messages.length - 1] + if ( lastMessage && lastMessage.type === "ask" && !lastMessage.isAnswered && lastMessage.ask && - approvalAskTypes.includes(lastMessage.ask) && - !lastMessage.partial + approvalAskTypes.includes(lastMessage.ask) ) { - return lastMessage + // command_output asks can be partial (while command is running) + // All other asks must be complete (not partial) to show approval + if (lastMessage.ask === "command_output" || !lastMessage.partial) { + return lastMessage + } } return null }) diff --git a/cli/src/state/hooks/useApprovalHandler.ts b/cli/src/state/hooks/useApprovalHandler.ts index a69968e3b87..8fcc30e5a3a 100644 --- a/cli/src/state/hooks/useApprovalHandler.ts +++ b/cli/src/state/hooks/useApprovalHandler.ts @@ -15,6 +15,7 @@ import { approveCallbackAtom, rejectCallbackAtom, executeSelectedCallbackAtom, + sendTerminalOperationCallbackAtom, type ApprovalOption, } from "../atoms/approval.js" import { addAllowedCommandAtom, autoApproveExecuteAllowedAtom } from "../atoms/config.js" @@ -26,6 +27,10 @@ import { useApprovalTelemetry } from "./useApprovalTelemetry.js" const APPROVAL_MENU_DELAY_MS = 500 +// command_output asks should show immediately without delay +// since they represent running commands that users need to be able to abort +const COMMAND_OUTPUT_DELAY_MS = 0 + /** * Options for useApprovalHandler hook */ @@ -58,6 +63,8 @@ export interface UseApprovalHandlerReturn { reject: (text?: string, images?: string[]) => Promise /** Execute the currently selected option */ executeSelected: (text?: string, images?: string[]) => Promise + /** Send terminal operation (continue or abort) */ + sendTerminalOperation: (operation: "continue" | "abort") => Promise } /** @@ -106,6 +113,7 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { const setApproveCallback = useSetAtom(approveCallbackAtom) const setRejectCallback = useSetAtom(rejectCallbackAtom) const setExecuteSelectedCallback = useSetAtom(executeSelectedCallbackAtom) + const setSendTerminalOperationCallback = useSetAtom(sendTerminalOperationCallbackAtom) const { sendAskResponse, sendMessage } = useWebviewMessage() const approvalTelemetry = useApprovalTelemetry() @@ -284,13 +292,108 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { [store, sendAskResponse, approvalTelemetry], ) + const sendTerminalOperation = useCallback( + async (operation: "continue" | "abort") => { + // Read the current state directly from the store at call time + const currentPendingApproval = store.get(pendingApprovalAtom) + const processingState = store.get(approvalProcessingAtom) + + if (!currentPendingApproval) { + logs.warn("No pending approval for terminal operation", "useApprovalHandler", { + operation, + }) + return + } + + // Verify this is a command_output ask + if (currentPendingApproval.ask !== "command_output") { + logs.warn("Terminal operation called for non-command_output ask", "useApprovalHandler", { + ask: currentPendingApproval.ask, + operation, + }) + return + } + + // Check if already processing + if (processingState.isProcessing) { + logs.warn("Approval already being processed, skipping duplicate", "useApprovalHandler", { + processingTs: processingState.processingTs, + currentTs: currentPendingApproval.ts, + }) + return + } + + // Start processing atomically + const started = store.set(startApprovalProcessingAtom, operation === "continue" ? "approve" : "reject") + if (!started) { + logs.warn("Failed to start terminal operation processing", "useApprovalHandler") + return + } + + try { + logs.debug(`Sending terminal operation: ${operation}`, "useApprovalHandler", { + ts: currentPendingApproval.ts, + }) + + // Mark message as answered locally BEFORE sending operation + const answeredMessage: ExtensionChatMessage = { + ...currentPendingApproval, + isAnswered: true, + } + store.set(updateChatMessageByTsAtom, answeredMessage) + + // For terminal operations, we only need to send the terminal operation message + // DO NOT send askResponse - that would resolve the wrong ask (completion_result instead of command_output) + // The backend's onLine callback handles the command_output ask internally + await sendMessage({ + type: "terminalOperation", + terminalOperation: operation, + }) + + logs.debug("Terminal operation sent successfully", "useApprovalHandler", { + operation, + ts: currentPendingApproval.ts, + }) + + // Track the operation as manual approval/rejection + if (operation === "continue") { + approvalTelemetry.trackManualApproval(currentPendingApproval) + } else { + approvalTelemetry.trackManualRejection(currentPendingApproval) + } + + // Complete processing atomically + store.set(completeApprovalProcessingAtom) + } catch (error) { + logs.error("Failed to send terminal operation", "useApprovalHandler", { error, operation }) + // Reset processing state on error so user can retry + store.set(completeApprovalProcessingAtom) + throw error + } + }, + [store, sendMessage, sendAskResponse, approvalTelemetry], + ) + const executeSelected = useCallback( async (text?: string, images?: string[]) => { + // Read the current state to check if this is a command_output + const currentPendingApproval = store.get(pendingApprovalAtom) + if (!selectedOption) { logs.warn("No option selected", "useApprovalHandler") return } + // Special handling for command_output - use terminal operations instead + if (currentPendingApproval?.ask === "command_output") { + if (selectedOption.action === "approve") { + await sendTerminalOperation("continue") + } else { + await sendTerminalOperation("abort") + } + return + } + if (selectedOption.action === "approve") { await approve(text, images) } else if (selectedOption.action === "approve-and-remember") { @@ -318,7 +421,7 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { await reject(text, images) } }, - [selectedOption, approve, reject, store], + [selectedOption, approve, reject, sendTerminalOperation, store], ) // Set callbacks for keyboard handler to use @@ -326,7 +429,17 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { setApproveCallback(() => approve) setRejectCallback(() => reject) setExecuteSelectedCallback(() => executeSelected) - }, [approve, reject, executeSelected, setApproveCallback, setRejectCallback, setExecuteSelectedCallback]) + setSendTerminalOperationCallback(() => sendTerminalOperation) + }, [ + approve, + reject, + executeSelected, + sendTerminalOperation, + setApproveCallback, + setRejectCallback, + setExecuteSelectedCallback, + setSendTerminalOperationCallback, + ]) // Manage delayed visibility of approval menu useEffect(() => { @@ -336,9 +449,13 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { return } + // Determine delay based on ask type + // command_output asks should show immediately so users can abort running commands + const delay = pendingApproval?.ask === "command_output" ? COMMAND_OUTPUT_DELAY_MS : APPROVAL_MENU_DELAY_MS + // Calculate remaining time for delay const elapsed = Date.now() - approvalSetTimestamp - const remaining = Math.max(0, APPROVAL_MENU_DELAY_MS - elapsed) + const remaining = Math.max(0, delay - elapsed) // Set timeout to show menu after delay const timeoutId = setTimeout(() => { @@ -348,7 +465,7 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { return () => { clearTimeout(timeoutId) } - }, [approvalSetTimestamp, isApprovalPendingImmediate]) + }, [approvalSetTimestamp, isApprovalPendingImmediate, pendingApproval?.ask]) return { pendingApproval, @@ -361,5 +478,6 @@ export function useApprovalHandler(): UseApprovalHandlerReturn { approve, reject, executeSelected, + sendTerminalOperation, } } diff --git a/cli/src/ui/components/ApprovalMenu.tsx b/cli/src/ui/components/ApprovalMenu.tsx index 0f4b7c4598f..536c6995ffa 100644 --- a/cli/src/ui/components/ApprovalMenu.tsx +++ b/cli/src/ui/components/ApprovalMenu.tsx @@ -23,9 +23,6 @@ export const ApprovalMenu: React.FC = ({ options, selectedInd return ( - - [!] Action Required: - {options.map((option, index) => ( = ({ option, isSelecte const themeColor = getThemeColor(option.color) const color = isSelected ? themeColor : theme.ui.text.primary - const icon = option.action === "approve" ? "✓" : "✗" + // Use appropriate icon based on action type + const icon = option.action === "approve" || option.action === "approve-and-remember" ? "✓" : "✗" return ( diff --git a/cli/src/ui/components/CommandInput.tsx b/cli/src/ui/components/CommandInput.tsx index 17df6216aad..1ff7b2fe749 100644 --- a/cli/src/ui/components/CommandInput.tsx +++ b/cli/src/ui/components/CommandInput.tsx @@ -136,7 +136,7 @@ export const CommandInput: React.FC = ({ : isShellMode ? "Type shell command..." : isApprovalPending - ? "Awaiting approval..." + ? "Actions available:" : placeholder return ( diff --git a/cli/src/ui/messages/extension/AskMessageRouter.tsx b/cli/src/ui/messages/extension/AskMessageRouter.tsx index 72f04854f89..a2a23952169 100644 --- a/cli/src/ui/messages/extension/AskMessageRouter.tsx +++ b/cli/src/ui/messages/extension/AskMessageRouter.tsx @@ -8,6 +8,7 @@ import { AskToolMessage, AskMistakeLimitMessage, AskCommandMessage, + AskCommandOutputMessage, AskUseMcpServerMessage, AskFollowupMessage, AskCondenseMessage, @@ -52,7 +53,7 @@ export const AskMessageRouter: React.FC = ({ message }) = return case "command_output": - return null + return case "browser_action_launch": return diff --git a/cli/src/ui/messages/extension/SayMessageRouter.tsx b/cli/src/ui/messages/extension/SayMessageRouter.tsx index 8db3563e902..8edebf59268 100644 --- a/cli/src/ui/messages/extension/SayMessageRouter.tsx +++ b/cli/src/ui/messages/extension/SayMessageRouter.tsx @@ -24,7 +24,6 @@ import { SayMcpServerResponseMessage, SayApiReqFinishedMessage, SayApiReqRetryDelayedMessage, - SayCommandOutputMessage, } from "./say/index.js" /** @@ -108,7 +107,7 @@ export const SayMessageRouter: React.FC = ({ message }) = return case "command_output": - return + return null // Handled in AskMessageRouter default: return diff --git a/cli/src/ui/messages/extension/__tests__/ExtensionMessageRow.test.tsx b/cli/src/ui/messages/extension/__tests__/ExtensionMessageRow.test.tsx index b8d8114ec07..19b2cce937b 100644 --- a/cli/src/ui/messages/extension/__tests__/ExtensionMessageRow.test.tsx +++ b/cli/src/ui/messages/extension/__tests__/ExtensionMessageRow.test.tsx @@ -183,21 +183,6 @@ describe("ExtensionMessageRow", () => { expect(lastFrame()).not.toContain("Unknown message type") }) - it("should route 'say' message with type 'command_output' to SayCommandOutputMessage", () => { - const message: ExtensionChatMessage = { - ts: Date.now(), - type: "say", - say: "command_output", - text: "Command executed successfully", - } - - const { lastFrame } = render() - - expect(lastFrame()).toBeDefined() - expect(lastFrame()).toContain("Command executed successfully") - expect(lastFrame()).not.toContain("Unknown say type") - }) - it("should show default message for unknown 'say' type", () => { const message: ExtensionChatMessage = { ts: Date.now(), diff --git a/cli/src/ui/messages/extension/ask/AskCommandOutputMessage.tsx b/cli/src/ui/messages/extension/ask/AskCommandOutputMessage.tsx new file mode 100644 index 00000000000..65ef3fd949b --- /dev/null +++ b/cli/src/ui/messages/extension/ask/AskCommandOutputMessage.tsx @@ -0,0 +1,68 @@ +import React from "react" +import { Box, Text } from "ink" +import { useAtomValue } from "jotai" +import type { MessageComponentProps } from "../types.js" +import { getMessageIcon } from "../utils.js" +import { useTheme } from "../../../../state/hooks/useTheme.js" +import { getBoxWidth } from "../../../utils/width.js" +import { pendingOutputUpdatesAtom } from "../../../../state/atoms/effects.js" + +export const AskCommandOutputMessage: React.FC = ({ message }) => { + const theme = useTheme() + const pendingUpdates = useAtomValue(pendingOutputUpdatesAtom) + + const icon = getMessageIcon("ask", "command_output") + + // Parse the message text to get initial command and executionId + let executionId = "" + let initialCommand = "" + try { + const data = JSON.parse(message.text || "{}") + executionId = data.executionId || "" + initialCommand = data.command || "" + } catch { + // If parsing fails, use text directly + initialCommand = message.text || "" + } + + // Get real-time output from pending updates (similar to webview's streamingOutput) + const pendingUpdate = executionId ? pendingUpdates.get(executionId) : undefined + const command = pendingUpdate?.command || initialCommand + const output = pendingUpdate?.output || "" + + return ( + + + + {icon} Command Running + + + + {command && ( + + {command} + + )} + + {output.trim() ? ( + + + {output.trim().length > 500 ? output.trim().slice(0, 500) + "\n..." : output.trim()} + + + ) : null} + + ) +} diff --git a/cli/src/ui/messages/extension/ask/index.ts b/cli/src/ui/messages/extension/ask/index.ts index 80c25843bb0..59793bda40d 100644 --- a/cli/src/ui/messages/extension/ask/index.ts +++ b/cli/src/ui/messages/extension/ask/index.ts @@ -1,6 +1,7 @@ export { AskToolMessage } from "./AskToolMessage.js" export { AskMistakeLimitMessage } from "./AskMistakeLimitMessage.js" export { AskCommandMessage } from "./AskCommandMessage.js" +export { AskCommandOutputMessage } from "./AskCommandOutputMessage.js" export { AskUseMcpServerMessage } from "./AskUseMcpServerMessage.js" export { AskFollowupMessage } from "./AskFollowupMessage.js" export { AskCondenseMessage } from "./AskCondenseMessage.js" diff --git a/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts b/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts index 9245b8bf5c2..326546fd30f 100644 --- a/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts +++ b/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts @@ -232,17 +232,30 @@ describe("messageCompletion", () => { expect(isMessageComplete(message)).toBe(true) }) - it("should return true for non-rendering ask types (command_output)", () => { - const message: UnifiedMessage = { + it("should return false for command_output ask type until not partial", () => { + const partialMessage: UnifiedMessage = { source: "extension", message: { ts: Date.now(), type: "ask", ask: "command_output", text: "", + partial: true, }, } - expect(isMessageComplete(message)).toBe(true) + expect(isMessageComplete(partialMessage)).toBe(false) + + const completeMessage: UnifiedMessage = { + source: "extension", + message: { + ts: Date.now(), + type: "ask", + ask: "command_output", + text: "", + partial: false, + }, + } + expect(isMessageComplete(completeMessage)).toBe(true) }) }) }) diff --git a/cli/src/ui/messages/utils/messageCompletion.ts b/cli/src/ui/messages/utils/messageCompletion.ts index c41ae695215..c60b476551d 100644 --- a/cli/src/ui/messages/utils/messageCompletion.ts +++ b/cli/src/ui/messages/utils/messageCompletion.ts @@ -45,7 +45,7 @@ function isExtensionMessageComplete(message: ExtensionChatMessage): boolean { // Ask messages completion logic if (message.type === "ask") { // These ask types don't render, so they're immediately complete - const nonRenderingAskTypes = ["completion_result", "command_output"] + const nonRenderingAskTypes = ["completion_result"] if (message.ask && nonRenderingAskTypes.includes(message.ask)) { return true }