Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export enum Command {
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',

// Step-through mode controls
STEP_NEXT = 'step.next',
STEP_SKIP = 'step.skip',
STEP_CONTINUE = 'step.continue',
}

/**
Expand Down Expand Up @@ -257,6 +262,11 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }, { key: 'r', shift: true }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],

// Step-through mode controls
[Command.STEP_NEXT]: [{ key: 'return' }, { key: 'n' }],
[Command.STEP_SKIP]: [{ key: 's' }],
[Command.STEP_CONTINUE]: [{ key: 'c' }],
};

interface CommandCategory {
Expand Down Expand Up @@ -379,6 +389,10 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SUSPEND_APP,
],
},
{
title: 'Step-Through Mode',
commands: [Command.STEP_NEXT, Command.STEP_SKIP, Command.STEP_CONTINUE],
},
];

/**
Expand Down Expand Up @@ -485,4 +499,12 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',

// Step-through mode controls
[Command.STEP_NEXT]:
'In step-through mode, execute the current tool and advance to the next.',
[Command.STEP_SKIP]:
'In step-through mode, skip the current tool without executing it.',
[Command.STEP_CONTINUE]:
'Exit step-through mode and resume automatic execution.',
};
9 changes: 6 additions & 3 deletions packages/cli/src/ui/components/ApprovalModeIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = 'auto-accept edits';
subText = allowPlanMode
? `${cycleHint} to plan`
: `${cycleHint} to manual`;
subText = allowPlanMode ? `${cycleHint} to step` : `${cycleHint} to step`;
break;
case ApprovalMode.STEP:
textColor = theme.text.accent;
textContent = 'step-through';
subText = `${cycleHint} to ${allowPlanMode ? 'plan' : 'manual'}`;
break;
case ApprovalMode.PLAN:
textColor = theme.status.success;
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
case ApprovalMode.AUTO_EDIT:
modeBleedThrough = { text: 'auto edit', color: theme.status.warning };
break;
case ApprovalMode.STEP:
modeBleedThrough = { text: 'step-through', color: theme.text.accent };
break;
case ApprovalMode.DEFAULT:
modeBleedThrough = null;
break;
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,15 @@ export function useApprovalModeIndicator({
: ApprovalMode.YOLO;
} else if (keyMatchers[Command.CYCLE_APPROVAL_MODE](key)) {
const currentMode = config.getApprovalMode();
// Cycle: DEFAULT → AUTO_EDIT → STEP → [PLAN if allowed] → DEFAULT
switch (currentMode) {
case ApprovalMode.DEFAULT:
nextApprovalMode = ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.AUTO_EDIT:
nextApprovalMode = ApprovalMode.STEP;
break;
case ApprovalMode.STEP:
nextApprovalMode = allowPlanMode
? ApprovalMode.PLAN
: ApprovalMode.DEFAULT;
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/confirmation-bus/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export enum MessageBusType {
TOOL_CALLS_UPDATE = 'tool-calls-update',
ASK_USER_REQUEST = 'ask-user-request',
ASK_USER_RESPONSE = 'ask-user-response',
/** Scheduler → UI: pause before executing a tool in step-through mode. */
STEP_THROUGH_REQUEST = 'step-through-request',
/** UI → Scheduler: user decision after reviewing a step-through prompt. */
STEP_THROUGH_RESPONSE = 'step-through-response',
}

export interface ToolCallsUpdateMessage {
Expand Down Expand Up @@ -178,6 +182,30 @@ export interface AskUserResponse {
cancelled?: boolean;
}

/** The action the user chose in the step-through dialog. */
export type StepThroughAction = 'next' | 'skip' | 'continue' | 'cancel';

/** Published by the Scheduler when it pauses before executing a tool in STEP mode. */
export interface StepThroughRequest {
type: MessageBusType.STEP_THROUGH_REQUEST;
correlationId: string;
callId: string;
toolName: string;
toolArgs: Record<string, unknown>;
toolDescription?: string;
/** 1-based position of this step within the current batch. */
stepIndex: number;
/** Estimated total number of steps in the current batch. */
stepTotal: number;
}

/** Published by the UI when the user decides what to do in step-through mode. */
export interface StepThroughResponse {
type: MessageBusType.STEP_THROUGH_RESPONSE;
correlationId: string;
action: StepThroughAction;
}

export type Message =
| ToolConfirmationRequest
| ToolConfirmationResponse
Expand All @@ -187,4 +215,6 @@ export type Message =
| UpdatePolicy
| AskUserRequest
| AskUserResponse
| ToolCallsUpdateMessage;
| ToolCallsUpdateMessage
| StepThroughRequest
| StepThroughResponse;
2 changes: 2 additions & 0 deletions packages/core/src/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export enum ApprovalMode {
AUTO_EDIT = 'autoEdit',
YOLO = 'yolo',
PLAN = 'plan',
/** Pauses before every tool call regardless of kind, giving the user a chance to inspect inputs and step through execution. */
STEP = 'step',
}

/**
Expand Down
65 changes: 64 additions & 1 deletion packages/core/src/scheduler/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
type ScheduledToolCall,
} from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import { PolicyDecision, ApprovalMode } from '../policy/types.js';
import { pauseForStepThrough } from './step-through.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
Expand Down Expand Up @@ -107,6 +108,11 @@ export class Scheduler {
private isCancelling = false;
private readonly requestQueue: SchedulerQueueItem[] = [];

/** Tracks how many tool calls have been stepped through in the current batch. */
private stepIndex = 0;
/** Total tool calls enqueued in the current batch, used for the step counter. */
private stepTotal = 0;

constructor(options: SchedulerOptions) {
this.config = options.config;
this.messageBus = options.messageBus;
Expand Down Expand Up @@ -291,6 +297,8 @@ export class Scheduler {
this.isProcessing = true;
this.isCancelling = false;
this.state.clearBatch();
this.stepIndex = 0;
this.stepTotal = requests.length;
const currentApprovalMode = this.config.getApprovalMode();

try {
Expand Down Expand Up @@ -640,6 +648,61 @@ export class Scheduler {
);
return false;
}

// Step-through mode: pause before every tool call and await user approval.
if (this.config.getApprovalMode() === ApprovalMode.STEP) {
this.stepIndex += 1;
const action = await pauseForStepThrough(
toolCall,
signal,
this.messageBus,
this.stepIndex,
Math.max(this.stepTotal, this.stepIndex),
);

switch (action) {
case 'skip': {
// Return an empty successful result without executing the tool.
this.state.updateStatus(callId, CoreToolCallStatus.Success, {
callId,
responseParts: [
{
functionResponse: {
id: callId,
name: toolCall.request.name,
response: {
output: '(skipped by user in step-through mode)',
},
},
},
],
resultDisplay: '(skipped)',
error: undefined,
errorType: undefined,
});
return false;
}
case 'cancel': {
this.state.updateStatus(
callId,
CoreToolCallStatus.Cancelled,
'Step-through cancelled by user.',
);
this.state.cancelAllQueued('Step-through cancelled by user');
return false;
}
case 'continue': {
// Exit step-through mode and resume normal execution for this and all
// subsequent tool calls.
this.config.setApprovalMode(ApprovalMode.DEFAULT);
break;
}
case 'next':
default:
break;
}
}

this.state.updateStatus(callId, CoreToolCallStatus.Executing);

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/scheduler/step-through.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { on } from 'node:events';
import { randomUUID } from 'node:crypto';
import {
MessageBusType,
type StepThroughAction,
type StepThroughResponse,
} from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ScheduledToolCall } from './types.js';

/**
* Pauses execution before a tool fires in STEP mode.
*
* Publishes a STEP_THROUGH_REQUEST on the MessageBus, then waits for a
* matching STEP_THROUGH_RESPONSE from the UI. The caller is responsible for
* interpreting the returned action:
* 'next' — proceed with execution
* 'skip' — return an empty result without executing
* 'continue' — exit step-through and proceed
* 'cancel' — abort the entire agent turn
*/
export async function pauseForStepThrough(
toolCall: ScheduledToolCall,
signal: AbortSignal,
messageBus: MessageBus,
stepIndex: number,
stepTotal: number,
): Promise<StepThroughAction> {
if (signal.aborted) {
return 'cancel';
}

const correlationId = randomUUID();

// Announce the pending step to the UI.
await messageBus.publish({
type: MessageBusType.STEP_THROUGH_REQUEST,
correlationId,
callId: toolCall.request.callId,
toolName: toolCall.request.name,
toolArgs: toolCall.request.args,
stepIndex,
stepTotal,
});

// Await the matching response, honouring the abort signal.
try {
for await (const [msg] of on(
messageBus,
MessageBusType.STEP_THROUGH_RESPONSE,
{ signal },
)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const response = msg as StepThroughResponse;
if (response.correlationId === correlationId) {
return response.action;
}
}
} catch {
// AbortError or iterator close — treat as cancel.
return 'cancel';
}

return 'cancel';
}
2 changes: 2 additions & 0 deletions packages/core/src/utils/approvalModeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function getApprovalModeDescription(mode: ApprovalMode): string {
return 'Plan mode (read-only planning)';
case ApprovalMode.YOLO:
return 'YOLO mode (all tool calls auto-approved)';
case ApprovalMode.STEP:
return 'Step-Through mode (pause before every tool call)';
default:
return checkExhaustive(mode);
}
Expand Down