From 3d2195f0740acdf4d1805ee750c75a82c33765f6 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Thu, 5 Feb 2026 18:47:08 -0500 Subject: [PATCH] feat: implement execution control (pause/resume/abort) for agent mode Adds execution control system per GitHub issue #113: - Ctrl+P: Toggle pause/resume during agent execution - Ctrl+Z: Abort with rollback (undo file changes) - Ctrl+Shift+S: Toggle step-by-step mode - Enter: Advance one step when in step mode New files: - src/types/execution-control.ts: Type definitions - src/services/execution-control.ts: Control implementation with rollback - src/constants/execution-control.ts: Keyboard shortcuts and messages Modified: - agent-stream.ts: Integrated execution control into agent loop - message-handler.ts: Added control functions and callbacks - app.tsx: Added keyboard shortcut handlers - help-content.ts: Added help topics for new shortcuts Closes #113 --- src/constants/execution-control.ts | 53 +++++ src/constants/help-content.ts | 27 +++ src/services/agent-stream.ts | 190 ++++++++++++++++-- src/services/chat-tui-service.ts | 6 + src/services/chat-tui/message-handler.ts | 163 ++++++++++++++- src/services/execution-control.ts | 245 +++++++++++++++++++++++ src/tui-solid/app.tsx | 74 ++++++- src/types/execution-control.ts | 126 ++++++++++++ 8 files changed, 853 insertions(+), 31 deletions(-) create mode 100644 src/constants/execution-control.ts create mode 100644 src/services/execution-control.ts create mode 100644 src/types/execution-control.ts diff --git a/src/constants/execution-control.ts b/src/constants/execution-control.ts new file mode 100644 index 0000000..0364bf7 --- /dev/null +++ b/src/constants/execution-control.ts @@ -0,0 +1,53 @@ +/** + * Execution Control Constants + * + * Constants for agent execution control (pause/resume/abort). + */ + +/** + * Keyboard shortcuts for execution control + */ +export const EXECUTION_CONTROL_KEYS = { + /** Toggle pause/resume (Ctrl+P) */ + TOGGLE_PAUSE: "ctrl+p", + /** Abort execution (Ctrl+C) */ + ABORT: "ctrl+c", + /** Abort with rollback (Ctrl+Z) */ + ABORT_WITH_ROLLBACK: "ctrl+z", + /** Toggle step mode (Ctrl+Shift+S) */ + TOGGLE_STEP_MODE: "ctrl+shift+s", + /** Advance one step in step mode (Enter when waiting) */ + STEP: "return", +} as const; + +/** + * Execution control status messages + */ +export const EXECUTION_CONTROL_MESSAGES = { + PAUSED: "⏸ Execution paused. Press Ctrl+P to resume.", + RESUMED: "▶ Execution resumed.", + STEP_MODE_ENABLED: "🚶 Step mode enabled. Press Enter to advance each tool call.", + STEP_MODE_DISABLED: "🏃 Step mode disabled. Execution will continue automatically.", + WAITING_FOR_STEP: (toolName: string): string => + `⏳ Step mode: Ready to execute ${toolName}. Press Enter to continue.`, + ROLLBACK_ACTION: (description: string): string => + `↩ Rolling back: ${description}`, + ROLLBACK_COMPLETE: (count: number): string => + `✓ Rollback complete. ${count} action(s) undone.`, + ABORT_INITIATED: (rollbackCount: number): string => + rollbackCount > 0 + ? `Aborting with rollback of ${rollbackCount} action(s)...` + : "Aborting execution...", +} as const; + +/** + * Help text for execution control + */ +export const EXECUTION_CONTROL_HELP = ` +Execution Control: + Ctrl+P - Toggle pause/resume + Ctrl+C - Abort execution + Ctrl+Z - Abort with rollback (undo file changes) + Ctrl+Shift+S - Toggle step-by-step mode + Enter - Advance one step (when in step mode) +` as const; diff --git a/src/constants/help-content.ts b/src/constants/help-content.ts index e0d23de..8367d14 100644 --- a/src/constants/help-content.ts +++ b/src/constants/help-content.ts @@ -173,6 +173,33 @@ export const HELP_TOPICS: HelpTopic[] = [ shortcuts: ["Ctrl+M"], category: "shortcuts", }, + { + id: "shortcut-ctrlp", + name: "Ctrl+P", + shortDescription: "Pause/Resume", + fullDescription: + "Toggle pause/resume during agent execution. When paused, tool calls are suspended until resumed.", + shortcuts: ["Ctrl+P"], + category: "shortcuts", + }, + { + id: "shortcut-ctrlz", + name: "Ctrl+Z", + shortDescription: "Abort with rollback", + fullDescription: + "Abort current operation and rollback file changes made during this execution. Use to undo unwanted modifications.", + shortcuts: ["Ctrl+Z"], + category: "shortcuts", + }, + { + id: "shortcut-ctrlshifts", + name: "Ctrl+Shift+S", + shortDescription: "Toggle step mode", + fullDescription: + "Enable step-by-step mode where you can advance one tool call at a time. Press Enter to execute each tool.", + shortcuts: ["Ctrl+Shift+S"], + category: "shortcuts", + }, ]; export const getTopicsByCategory = (category: HelpCategory): HelpTopic[] => diff --git a/src/services/agent-stream.ts b/src/services/agent-stream.ts index a15e48e..886cdf5 100644 --- a/src/services/agent-stream.ts +++ b/src/services/agent-stream.ts @@ -3,6 +3,7 @@ * * Agent loop that streams LLM responses in real-time to the TUI. * Handles tool call accumulation mid-stream. + * Supports pause/resume, step-by-step mode, and abort with rollback. */ import { v4 as uuidv4 } from "uuid"; @@ -20,11 +21,16 @@ import type { PartialToolCall, StreamCallbacks, } from "@/types/streaming"; +import type { ExecutionControlEvents } from "@/types/execution-control"; import { chatStream } from "@providers/core/chat"; import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index"; import { initializePermissions } from "@services/core/permissions"; import { MAX_ITERATIONS, MAX_CONSECUTIVE_ERRORS } from "@constants/agent"; import { createStreamAccumulator } from "@/types/streaming"; +import { + createExecutionControl, + captureFileState, +} from "@services/execution-control"; // ============================================================================= // Types @@ -36,6 +42,21 @@ interface StreamAgentState { abort: AbortController; options: AgentOptions; callbacks: Partial; + executionControl: ReturnType; +} + +/** + * Extended stream callbacks with execution control events + */ +export interface ExtendedStreamCallbacks extends StreamCallbacks { + onPause?: () => void; + onResume?: () => void; + onStepModeEnabled?: () => void; + onStepModeDisabled?: () => void; + onWaitingForStep?: (toolName: string, toolArgs: Record) => void; + onAbort?: (rollbackCount: number) => void; + onRollback?: (action: { type: string; description: string }) => void; + onRollbackComplete?: (actionsRolledBack: number) => void; } // ============================================================================= @@ -45,14 +66,34 @@ interface StreamAgentState { const createStreamAgentState = ( workingDir: string, options: AgentOptions, - callbacks: Partial, -): StreamAgentState => ({ - sessionId: uuidv4(), - workingDir, - abort: new AbortController(), - options, - callbacks, -}); + callbacks: Partial, +): StreamAgentState => { + const extendedCallbacks = callbacks as Partial; + + const executionControlEvents: ExecutionControlEvents = { + onPause: extendedCallbacks.onPause, + onResume: extendedCallbacks.onResume, + onStepModeEnabled: extendedCallbacks.onStepModeEnabled, + onStepModeDisabled: extendedCallbacks.onStepModeDisabled, + onWaitingForStep: extendedCallbacks.onWaitingForStep, + onAbort: extendedCallbacks.onAbort, + onRollback: (action) => + extendedCallbacks.onRollback?.({ + type: action.type, + description: action.description, + }), + onRollbackComplete: extendedCallbacks.onRollbackComplete, + }; + + return { + sessionId: uuidv4(), + workingDir, + abort: new AbortController(), + options, + callbacks, + executionControl: createExecutionControl(executionControlEvents), + }; +}; // ============================================================================= // Tool Call Accumulation @@ -251,10 +292,46 @@ const finalizeToolCall = (partial: PartialToolCall): ToolCall => { // Tool Execution // ============================================================================= +/** + * Tools that modify files and support rollback + */ +const ROLLBACK_CAPABLE_TOOLS: Record = { + write: "file_write", + edit: "file_edit", + delete: "file_delete", + bash: "bash_command", +}; + const executeTool = async ( state: StreamAgentState, toolCall: ToolCall, ): Promise => { + // Check if execution was aborted + if (state.executionControl.getState() === "aborted") { + return { + success: false, + title: "Aborted", + output: "", + error: "Execution was aborted", + }; + } + + // Wait if paused + await state.executionControl.waitIfPaused(); + + // Wait for step confirmation if in step mode + await state.executionControl.waitForStep(toolCall.name, toolCall.arguments); + + // Check again after waiting (might have been aborted while waiting) + if (state.executionControl.getState() === "aborted") { + return { + success: false, + title: "Aborted", + output: "", + error: "Execution was aborted", + }; + } + // Check for debug error markers from truncated/malformed JSON const debugError = toolCall.arguments.__debug_error as string | undefined; if (debugError) { @@ -292,9 +369,32 @@ const executeTool = async ( onMetadata: () => {}, }; + // Capture file state for rollback if this is a modifying tool + const rollbackType = ROLLBACK_CAPABLE_TOOLS[toolCall.name]; + let originalState: { filePath: string; content: string } | null = null; + + if (rollbackType && (rollbackType === "file_edit" || rollbackType === "file_delete")) { + const filePath = toolCall.arguments.file_path as string | undefined; + if (filePath) { + originalState = await captureFileState(filePath); + } + } + try { const validatedArgs = tool.parameters.parse(toolCall.arguments); - return await tool.execute(validatedArgs, ctx); + const result = await tool.execute(validatedArgs, ctx); + + // Record action for rollback if successful and modifying + if (result.success && rollbackType) { + const filePath = toolCall.arguments.file_path as string | undefined; + state.executionControl.recordAction({ + type: rollbackType, + description: `${toolCall.name}: ${filePath ?? "unknown file"}`, + originalState: originalState ?? (filePath ? { filePath, content: "" } : undefined), + }); + } + + return result; } catch (error: unknown) { const receivedArgs = JSON.stringify(toolCall.arguments); const errorMessage = error instanceof Error ? error.message : String(error); @@ -496,6 +596,19 @@ export const runAgentLoopStream = async ( const agentMessages: AgentMessage[] = [...messages]; while (iterations < maxIterations) { + // Check for abort at start of each iteration + if (state.executionControl.getState() === "aborted") { + return { + success: false, + finalResponse: "Execution aborted by user", + iterations, + toolCalls: allToolCalls, + }; + } + + // Wait if paused + await state.executionControl.waitIfPaused(); + iterations++; try { @@ -611,7 +724,7 @@ export const runStreamingAgent = async ( prompt: string, systemPrompt: string, options: AgentOptions, - callbacks: Partial = {}, + callbacks: Partial = {}, ): Promise => { const messages: Message[] = [ { role: "system", content: systemPrompt }, @@ -623,24 +736,63 @@ export const runStreamingAgent = async ( }; /** - * Create a streaming agent instance with stop capability + * Streaming agent instance with full execution control + */ +export interface StreamingAgentInstance { + /** Run the agent with given messages */ + run: (messages: Message[]) => Promise; + /** Stop the agent (abort without rollback) */ + stop: () => void; + /** Update callbacks */ + updateCallbacks: (newCallbacks: Partial) => void; + /** Pause execution */ + pause: () => void; + /** Resume execution */ + resume: () => void; + /** Abort with optional rollback */ + abort: (rollback?: boolean) => Promise; + /** Enable/disable step-by-step mode */ + stepMode: (enabled: boolean) => void; + /** Advance one step in step mode */ + step: () => void; + /** Get current execution state */ + getExecutionState: () => "running" | "paused" | "stepping" | "aborted" | "completed"; + /** Check if waiting for step confirmation */ + isWaitingForStep: () => boolean; + /** Get count of rollback actions available */ + getRollbackCount: () => number; +} + +/** + * Create a streaming agent instance with full execution control */ export const createStreamingAgent = ( workingDir: string, options: AgentOptions, - callbacks: Partial = {}, -): { - run: (messages: Message[]) => Promise; - stop: () => void; - updateCallbacks: (newCallbacks: Partial) => void; -} => { + callbacks: Partial = {}, +): StreamingAgentInstance => { const state = createStreamAgentState(workingDir, options, callbacks); + const control = state.executionControl; return { run: (messages: Message[]) => runAgentLoopStream(state, messages), - stop: () => state.abort.abort(), - updateCallbacks: (newCallbacks: Partial) => { + stop: () => { + state.abort.abort(); + control.abort(false); + }, + updateCallbacks: (newCallbacks: Partial) => { Object.assign(state.callbacks, newCallbacks); }, + pause: () => control.pause(), + resume: () => control.resume(), + abort: (rollback = false) => control.abort(rollback), + stepMode: (enabled: boolean) => control.stepMode(enabled), + step: () => control.step(), + getExecutionState: () => control.getState(), + isWaitingForStep: () => control.isWaitingForStep(), + getRollbackCount: () => control.getRollbackActions().length, }; }; + +// Re-export types for external use +export type { ExecutionControl, ExecutionControlEvents } from "@/types/execution-control"; diff --git a/src/services/chat-tui-service.ts b/src/services/chat-tui-service.ts index b3d1a7d..3abd5ec 100644 --- a/src/services/chat-tui-service.ts +++ b/src/services/chat-tui-service.ts @@ -27,6 +27,12 @@ export { initializeChatService } from "@services/chat-tui/initialize"; export { handleMessage, abortCurrentOperation, + pauseCurrentOperation, + resumeCurrentOperation, + togglePauseResume, + setStepMode, + advanceStep, + getExecutionState, } from "@services/chat-tui/message-handler"; // Re-export command handling diff --git a/src/services/chat-tui/message-handler.ts b/src/services/chat-tui/message-handler.ts index 0722fbf..ca94e80 100644 --- a/src/services/chat-tui/message-handler.ts +++ b/src/services/chat-tui/message-handler.ts @@ -3,7 +3,10 @@ */ import { addMessage, saveSession } from "@services/core/session"; -import { createStreamingAgent } from "@services/agent-stream"; +import { + createStreamingAgent, + type StreamingAgentInstance, +} from "@services/agent-stream"; import { CHAT_MESSAGES } from "@constants/chat-service"; import { enrichMessageWithIssues } from "@services/github-issue-service"; import { checkGitHubCLI } from "@services/github-pr/cli"; @@ -74,26 +77,133 @@ let lastResponseContext: { response: string; } | null = null; -// Track current running agent for abort capability -let currentAgent: { stop: () => void } | null = null; +// Track current running agent for execution control +let currentAgent: StreamingAgentInstance | null = null; /** * Abort the currently running agent operation + * @param rollback - If true, attempt to rollback file changes * @returns true if an operation was aborted, false if nothing was running */ -export const abortCurrentOperation = (): boolean => { +export const abortCurrentOperation = async ( + rollback = false, +): Promise => { if (currentAgent) { - currentAgent.stop(); + await currentAgent.abort(rollback); currentAgent = null; appStore.cancelStreaming(); appStore.stopThinking(); appStore.setMode("idle"); - addDebugLog("state", "Operation aborted by user"); + addDebugLog( + "state", + rollback ? "Operation aborted with rollback" : "Operation aborted by user", + ); return true; } return false; }; +/** + * Pause the currently running agent operation + * @returns true if operation was paused, false if nothing was running + */ +export const pauseCurrentOperation = (): boolean => { + if (currentAgent && currentAgent.getExecutionState() === "running") { + currentAgent.pause(); + appStore.addLog({ + type: "system", + content: "⏸ Execution paused. Press Ctrl+P to resume.", + }); + addDebugLog("state", "Operation paused by user"); + return true; + } + return false; +}; + +/** + * Resume the currently paused agent operation + * @returns true if operation was resumed, false if nothing was paused + */ +export const resumeCurrentOperation = (): boolean => { + if (currentAgent && currentAgent.getExecutionState() === "paused") { + currentAgent.resume(); + appStore.addLog({ + type: "system", + content: "▶ Execution resumed.", + }); + addDebugLog("state", "Operation resumed by user"); + return true; + } + return false; +}; + +/** + * Toggle pause/resume for current operation + * @returns true if state changed, false if nothing running + */ +export const togglePauseResume = (): boolean => { + if (!currentAgent) return false; + + const state = currentAgent.getExecutionState(); + if (state === "running" || state === "stepping") { + return pauseCurrentOperation(); + } + if (state === "paused") { + return resumeCurrentOperation(); + } + return false; +}; + +/** + * Enable/disable step-by-step mode for current operation + * @param enabled - Whether step mode should be enabled + * @returns true if mode was changed + */ +export const setStepMode = (enabled: boolean): boolean => { + if (!currentAgent) return false; + + currentAgent.stepMode(enabled); + appStore.addLog({ + type: "system", + content: enabled + ? "🚶 Step mode enabled. Press Enter to advance each tool call." + : "🏃 Step mode disabled. Execution will continue automatically.", + }); + addDebugLog("state", `Step mode ${enabled ? "enabled" : "disabled"}`); + return true; +}; + +/** + * Advance one step in step-by-step mode + * @returns true if step was advanced + */ +export const advanceStep = (): boolean => { + if (currentAgent && currentAgent.isWaitingForStep()) { + currentAgent.step(); + addDebugLog("state", "Step advanced by user"); + return true; + } + return false; +}; + +/** + * Get current execution state + */ +export const getExecutionState = (): { + state: "idle" | "running" | "paused" | "stepping" | "aborted" | "completed"; + rollbackCount: number; + waitingForStep: boolean; +} => { + if (!currentAgent) { + return { state: "idle", rollbackCount: 0, waitingForStep: false }; + } + return { + state: currentAgent.getExecutionState(), + rollbackCount: currentAgent.getRollbackCount(), + waitingForStep: currentAgent.isWaitingForStep(), + }; +}; + const createToolCallHandler = ( callbacks: ChatServiceCallbacks, @@ -574,7 +684,46 @@ export const handleMessage = async ( callbacks.onLog("system", warning); }, }, - streamState.callbacks, + { + ...streamState.callbacks, + // Execution control callbacks + onPause: () => { + addDebugLog("state", "Execution paused"); + }, + onResume: () => { + addDebugLog("state", "Execution resumed"); + }, + onStepModeEnabled: () => { + addDebugLog("state", "Step mode enabled"); + }, + onStepModeDisabled: () => { + addDebugLog("state", "Step mode disabled"); + }, + onWaitingForStep: (toolName: string, _toolArgs: Record) => { + appStore.addLog({ + type: "system", + content: `⏳ Step mode: Ready to execute ${toolName}. Press Enter to continue.`, + }); + addDebugLog("state", `Waiting for step: ${toolName}`); + }, + onAbort: (rollbackCount: number) => { + addDebugLog("state", `Abort initiated, ${rollbackCount} actions to rollback`); + }, + onRollback: (action: { type: string; description: string }) => { + appStore.addLog({ + type: "system", + content: `↩ Rolling back: ${action.description}`, + }); + addDebugLog("state", `Rollback: ${action.type} - ${action.description}`); + }, + onRollbackComplete: (actionsRolledBack: number) => { + appStore.addLog({ + type: "system", + content: `✓ Rollback complete. ${actionsRolledBack} action(s) undone.`, + }); + addDebugLog("state", `Rollback complete: ${actionsRolledBack} actions`); + }, + }, ); // Store agent reference for abort capability diff --git a/src/services/execution-control.ts b/src/services/execution-control.ts new file mode 100644 index 0000000..91829d1 --- /dev/null +++ b/src/services/execution-control.ts @@ -0,0 +1,245 @@ +/** + * Execution Control Service + * + * Provides pause/resume, step-by-step mode, and abort with rollback + * for agent execution. Based on GitHub issue #113. + */ + +import { writeFile, unlink, readFile } from "fs/promises"; +import type { + ExecutionControl, + ExecutionControlState, + ExecutionControlEvents, + ExecutionState, + RollbackAction, +} from "@/types/execution-control"; +import { createExecutionControlState } from "@/types/execution-control"; + +/** + * Create an execution control instance + */ +export const createExecutionControl = ( + events: ExecutionControlEvents = {}, +): ExecutionControl & { + /** Internal: record an action for potential rollback */ + recordAction: (action: Omit) => void; + /** Internal: wait for resume if paused */ + waitIfPaused: () => Promise; + /** Internal: wait for step confirmation if in step mode */ + waitForStep: (toolName: string, toolArgs: Record) => Promise; + /** Internal: get the full state */ + getFullState: () => ExecutionControlState; +} => { + const state: ExecutionControlState = createExecutionControlState(); + + /** + * Wait for resume if paused + */ + const waitIfPaused = async (): Promise => { + if (state.state !== "paused") return; + + return new Promise((resolve) => { + state.resumeResolver = resolve; + }); + }; + + /** + * Wait for step confirmation in step mode + */ + const waitForStep = async ( + toolName: string, + toolArgs: Record, + ): Promise => { + if (!state.stepModeEnabled || state.state === "aborted") return; + + state.waitingForStep = true; + events.onWaitingForStep?.(toolName, toolArgs); + + return new Promise((resolve) => { + state.stepResolver = resolve; + }); + }; + + /** + * Record an action for potential rollback + */ + const recordAction = ( + action: Omit, + ): void => { + const fullAction: RollbackAction = { + ...action, + id: `action_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + timestamp: Date.now(), + }; + state.rollbackStack.push(fullAction); + }; + + /** + * Perform rollback of recorded actions + */ + const performRollback = async (): Promise => { + let rolledBack = 0; + + // Process in reverse order (LIFO) + while (state.rollbackStack.length > 0) { + const action = state.rollbackStack.pop(); + if (!action) continue; + + try { + await rollbackAction(action); + events.onRollback?.(action); + rolledBack++; + } catch (error) { + // Log but continue with other rollbacks + console.error(`Rollback failed for ${action.id}:`, error); + } + } + + events.onRollbackComplete?.(rolledBack); + return rolledBack; + }; + + /** + * Rollback a single action + */ + const rollbackAction = async (action: RollbackAction): Promise => { + const handlers: Record Promise> = { + file_write: async () => { + // Delete the created file + if (action.originalState?.filePath) { + await unlink(action.originalState.filePath).catch(() => {}); + } + }, + + file_edit: async () => { + // Restore original content + if (action.originalState?.filePath && action.originalState?.content) { + await writeFile( + action.originalState.filePath, + action.originalState.content, + "utf-8", + ); + } + }, + + file_delete: async () => { + // Restore deleted file + if (action.originalState?.filePath && action.originalState?.content) { + await writeFile( + action.originalState.filePath, + action.originalState.content, + "utf-8", + ); + } + }, + + bash_command: async () => { + // Bash commands cannot be reliably rolled back + // Just log the action that was performed + }, + }; + + const handler = handlers[action.type]; + if (handler) { + await handler(); + } + }; + + return { + pause: (): void => { + if (state.state === "running" || state.state === "stepping") { + state.state = "paused"; + events.onPause?.(); + } + }, + + resume: (): void => { + if (state.state === "paused") { + state.state = state.stepModeEnabled ? "stepping" : "running"; + events.onResume?.(); + + // Resolve any pending pause wait + if (state.resumeResolver) { + state.resumeResolver(); + state.resumeResolver = null; + } + } + }, + + abort: async (rollback = false): Promise => { + state.state = "aborted"; + + // Resolve any pending waits + if (state.resumeResolver) { + state.resumeResolver(); + state.resumeResolver = null; + } + if (state.stepResolver) { + state.stepResolver(); + state.stepResolver = null; + } + + const rollbackCount = rollback ? state.rollbackStack.length : 0; + events.onAbort?.(rollbackCount); + + if (rollback && state.rollbackStack.length > 0) { + await performRollback(); + } + + state.rollbackStack = []; + }, + + stepMode: (enabled: boolean): void => { + state.stepModeEnabled = enabled; + if (enabled) { + state.state = "stepping"; + events.onStepModeEnabled?.(); + } else { + if (state.state === "stepping") { + state.state = "running"; + } + events.onStepModeDisabled?.(); + } + }, + + step: (): void => { + if (state.waitingForStep && state.stepResolver) { + state.waitingForStep = false; + state.stepResolver(); + state.stepResolver = null; + } + }, + + getState: (): ExecutionState => state.state, + + getRollbackActions: (): RollbackAction[] => [...state.rollbackStack], + + isWaitingForStep: (): boolean => state.waitingForStep, + + // Internal methods + recordAction, + waitIfPaused, + waitForStep, + getFullState: () => ({ ...state }), + }; +}; + +/** + * Helper to capture file state before modification + */ +export const captureFileState = async ( + filePath: string, +): Promise<{ filePath: string; content: string } | null> => { + try { + const content = await readFile(filePath, "utf-8"); + return { filePath, content }; + } catch { + // File doesn't exist, which is fine for new files + return null; + } +}; + +/** + * Execution control factory type for dependency injection + */ +export type ExecutionControlFactory = typeof createExecutionControl; diff --git a/src/tui-solid/app.tsx b/src/tui-solid/app.tsx index f2120d5..5db9e97 100644 --- a/src/tui-solid/app.tsx +++ b/src/tui-solid/app.tsx @@ -9,7 +9,13 @@ import { } from "solid-js"; import { batch } from "solid-js"; import { getFiles } from "@services/file-picker/files"; -import { abortCurrentOperation } from "@services/chat-tui-service"; +import { + abortCurrentOperation, + togglePauseResume, + setStepMode, + advanceStep, + getExecutionState, +} from "@services/chat-tui-service"; import versionData from "@/version.json"; import { ExitProvider, useExit } from "@tui-solid/context/exit"; import { RouteProvider, useRoute } from "@tui-solid/context/route"; @@ -159,16 +165,74 @@ function AppContent(props: AppProps) { useKeyboard((evt) => { // ESC aborts current operation if (evt.name === "escape") { - const aborted = abortCurrentOperation(); - if (aborted) { - toast.info("Operation cancelled"); + abortCurrentOperation(false).then((aborted) => { + if (aborted) { + toast.info("Operation cancelled"); + } + }); + evt.preventDefault(); + return; + } + + // Ctrl+P toggles pause/resume during execution + if (evt.ctrl && evt.name === "p") { + const toggled = togglePauseResume(); + if (toggled) { + const state = getExecutionState(); + toast.info(state.state === "paused" ? "⏸ Execution paused" : "▶ Execution resumed"); evt.preventDefault(); return; } } - // Ctrl+C exits the application + // Ctrl+Z aborts with rollback + if (evt.ctrl && evt.name === "z") { + const state = getExecutionState(); + if (state.state !== "idle") { + abortCurrentOperation(true).then((aborted) => { + if (aborted) { + toast.info(`Aborted with rollback of ${state.rollbackCount} action(s)`); + } + }); + evt.preventDefault(); + return; + } + } + + // Ctrl+Shift+S toggles step mode + if (evt.ctrl && evt.shift && evt.name === "s") { + const state = getExecutionState(); + if (state.state !== "idle") { + const isStepMode = state.state === "stepping"; + setStepMode(!isStepMode); + toast.info(isStepMode ? "🏃 Step mode disabled" : "🚶 Step mode enabled"); + evt.preventDefault(); + return; + } + } + + // Enter advances step when waiting for step confirmation + if (evt.name === "return" && !evt.ctrl && !evt.shift) { + const state = getExecutionState(); + if (state.waitingForStep) { + advanceStep(); + evt.preventDefault(); + return; + } + } + + // Ctrl+C exits the application (with confirmation) if (evt.ctrl && evt.name === "c") { + // First try to abort current operation + const state = getExecutionState(); + if (state.state !== "idle") { + abortCurrentOperation(false).then(() => { + toast.info("Operation cancelled. Press Ctrl+C again to exit."); + }); + evt.preventDefault(); + return; + } + if (app.interruptPending()) { exit.exit(0); evt.preventDefault(); diff --git a/src/types/execution-control.ts b/src/types/execution-control.ts new file mode 100644 index 0000000..97bd730 --- /dev/null +++ b/src/types/execution-control.ts @@ -0,0 +1,126 @@ +/** + * Execution Control Types + * + * Types for controlling agent execution flow. + * Supports pause/resume, step-by-step mode, and abort with rollback. + */ + +/** + * Execution state values + */ +export type ExecutionState = + | "running" + | "paused" + | "stepping" + | "aborted" + | "completed"; + +/** + * Rollback action for undo on abort + */ +export interface RollbackAction { + /** Unique identifier for this action */ + id: string; + /** Type of action to rollback */ + type: "file_write" | "file_edit" | "file_delete" | "bash_command"; + /** Description of the action */ + description: string; + /** Original state before the action */ + originalState?: { + filePath?: string; + content?: string; + }; + /** Timestamp when action was performed */ + timestamp: number; +} + +/** + * Execution control interface + * Provides methods to control agent execution flow + */ +export interface ExecutionControl { + /** Pause the current execution */ + pause(): void; + /** Resume paused execution */ + resume(): void; + /** Abort execution with optional rollback */ + abort(rollback?: boolean): Promise; + /** Enable/disable step-by-step mode */ + stepMode(enabled: boolean): void; + /** Advance one step in step mode */ + step(): void; + /** Get current execution state */ + getState(): ExecutionState; + /** Get pending rollback actions */ + getRollbackActions(): RollbackAction[]; + /** Check if execution is waiting for step confirmation */ + isWaitingForStep(): boolean; +} + +/** + * Execution control state for internal tracking + */ +export interface ExecutionControlState { + /** Current execution state */ + state: ExecutionState; + /** Whether step-by-step mode is enabled */ + stepModeEnabled: boolean; + /** Whether currently waiting for step confirmation */ + waitingForStep: boolean; + /** Stack of rollback actions for undo on abort */ + rollbackStack: RollbackAction[]; + /** Promise resolver for pause/step resume */ + resumeResolver: (() => void) | null; + /** Step resolver for step-by-step mode */ + stepResolver: (() => void) | null; +} + +/** + * Execution control events + */ +export interface ExecutionControlEvents { + /** Called when execution is paused */ + onPause?: () => void; + /** Called when execution is resumed */ + onResume?: () => void; + /** Called when entering step mode */ + onStepModeEnabled?: () => void; + /** Called when step mode is disabled */ + onStepModeDisabled?: () => void; + /** Called when waiting for step confirmation */ + onWaitingForStep?: (toolName: string, toolArgs: Record) => void; + /** Called when abort is initiated */ + onAbort?: (rollbackCount: number) => void; + /** Called when a rollback action is performed */ + onRollback?: (action: RollbackAction) => void; + /** Called when rollback is complete */ + onRollbackComplete?: (actionsRolledBack: number) => void; +} + +/** + * Keyboard shortcuts for execution control + */ +export const EXECUTION_CONTROL_KEYS = { + /** Toggle pause/resume */ + togglePause: "ctrl+p", + /** Toggle step mode */ + toggleStepMode: "ctrl+s", + /** Advance one step in step mode */ + step: "enter", + /** Abort execution */ + abort: "ctrl+c", + /** Abort with rollback */ + abortWithRollback: "ctrl+z", +} as const; + +/** + * Default execution control state factory + */ +export const createExecutionControlState = (): ExecutionControlState => ({ + state: "running", + stepModeEnabled: false, + waitingForStep: false, + rollbackStack: [], + resumeResolver: null, + stepResolver: null, +});