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
This commit is contained in:
2026-02-05 18:47:08 -05:00
parent e2cb41f8d3
commit 3d2195f074
8 changed files with 853 additions and 31 deletions

View File

@@ -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;

View File

@@ -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[] =>

View File

@@ -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<StreamCallbacks>;
executionControl: ReturnType<typeof createExecutionControl>;
}
/**
* 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<string, unknown>) => 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<StreamCallbacks>,
): StreamAgentState => ({
sessionId: uuidv4(),
workingDir,
abort: new AbortController(),
options,
callbacks,
});
callbacks: Partial<ExtendedStreamCallbacks>,
): StreamAgentState => {
const extendedCallbacks = callbacks as Partial<ExtendedStreamCallbacks>;
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<string, "file_write" | "file_edit" | "file_delete" | "bash_command"> = {
write: "file_write",
edit: "file_edit",
delete: "file_delete",
bash: "bash_command",
};
const executeTool = async (
state: StreamAgentState,
toolCall: ToolCall,
): Promise<ToolResult> => {
// 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<StreamCallbacks> = {},
callbacks: Partial<ExtendedStreamCallbacks> = {},
): Promise<AgentResult> => {
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<AgentResult>;
/** Stop the agent (abort without rollback) */
stop: () => void;
/** Update callbacks */
updateCallbacks: (newCallbacks: Partial<ExtendedStreamCallbacks>) => void;
/** Pause execution */
pause: () => void;
/** Resume execution */
resume: () => void;
/** Abort with optional rollback */
abort: (rollback?: boolean) => Promise<void>;
/** 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<StreamCallbacks> = {},
): {
run: (messages: Message[]) => Promise<AgentResult>;
stop: () => void;
updateCallbacks: (newCallbacks: Partial<StreamCallbacks>) => void;
} => {
callbacks: Partial<ExtendedStreamCallbacks> = {},
): 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<StreamCallbacks>) => {
stop: () => {
state.abort.abort();
control.abort(false);
},
updateCallbacks: (newCallbacks: Partial<ExtendedStreamCallbacks>) => {
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";

View File

@@ -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

View File

@@ -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<boolean> => {
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<string, unknown>) => {
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

View File

@@ -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<RollbackAction, "id" | "timestamp">) => void;
/** Internal: wait for resume if paused */
waitIfPaused: () => Promise<void>;
/** Internal: wait for step confirmation if in step mode */
waitForStep: (toolName: string, toolArgs: Record<string, unknown>) => Promise<void>;
/** Internal: get the full state */
getFullState: () => ExecutionControlState;
} => {
const state: ExecutionControlState = createExecutionControlState();
/**
* Wait for resume if paused
*/
const waitIfPaused = async (): Promise<void> => {
if (state.state !== "paused") return;
return new Promise<void>((resolve) => {
state.resumeResolver = resolve;
});
};
/**
* Wait for step confirmation in step mode
*/
const waitForStep = async (
toolName: string,
toolArgs: Record<string, unknown>,
): Promise<void> => {
if (!state.stepModeEnabled || state.state === "aborted") return;
state.waitingForStep = true;
events.onWaitingForStep?.(toolName, toolArgs);
return new Promise<void>((resolve) => {
state.stepResolver = resolve;
});
};
/**
* Record an action for potential rollback
*/
const recordAction = (
action: Omit<RollbackAction, "id" | "timestamp">,
): 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<number> => {
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<void> => {
const handlers: Record<RollbackAction["type"], () => Promise<void>> = {
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<void> => {
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;

View File

@@ -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();

View File

@@ -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<void>;
/** 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<string, unknown>) => 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,
});