Compare commits

...

6 Commits

Author SHA1 Message Date
A.K.M. Adib b8efb55b1a put diff above confirmation details and create snapshot test 2026-04-01 17:00:54 -04:00
A.K.M. Adib e10154b5e4 refactor duplicated code 2026-04-01 10:03:24 -04:00
Adib234 ee5ab094e2 Update packages/core/src/tools/exit-plan-mode.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 09:44:13 -04:00
Adib234 8251ac52d6 Update packages/core/src/tools/exit-plan-mode.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 09:43:45 -04:00
A.K.M. Adib c3953d123b format files 2026-03-31 17:22:27 -04:00
A.K.M. Adib b6a6682f26 feat(core): support plan versioning and diffing
- Core: Save rejected plans as versioned backups (.v1, .v2, etc.)
   - Core: Compute diff between previous and current plan version in exit_plan_mode
   - UI: Display color-coded diff using DiffRenderer in plan approval dialog
   - UI: Update AskUserDialog and DialogFooter to support ReactNode in extraParts
   - Docs: Update planning documentation to mention versioning and diff features
   - Tests: Add comprehensive unit tests for versioning, diffing, and UI rendering

   Fixes #17794
2026-03-31 17:15:41 -04:00
12 changed files with 274 additions and 19 deletions
+3
View File
@@ -51,7 +51,10 @@ finalized plan to the user and requests approval to start the implementation.
- Marks the plan as approved for implementation.
- If the user rejects the plan:
- Stays in Plan Mode.
- Saves a versioned backup of the rejected plan.
- Returns user feedback to the model to refine the plan.
- The next time `exit_plan_mode` is called, a diff against the previous
version is shown in the approval step.
- **Output (`llmContent`):**
- On approval: A message indicating the plan was approved and the new approval
mode.
@@ -190,7 +190,11 @@ interface AskUserDialogProps {
/**
* Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"])
*/
extraParts?: string[];
extraParts?: React.ReactNode[];
/**
* Content to render before the options/input field.
*/
preOptionsContent?: React.ReactNode;
}
interface ReviewViewProps {
@@ -198,7 +202,8 @@ interface ReviewViewProps {
answers: { [key: string]: string };
onSubmit: () => void;
progressHeader?: React.ReactNode;
extraParts?: string[];
extraParts?: React.ReactNode[];
preOptionsContent?: React.ReactNode;
}
const ReviewView: React.FC<ReviewViewProps> = ({
@@ -207,6 +212,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
onSubmit,
progressHeader,
extraParts,
preOptionsContent,
}) => {
const keyMatchers = useKeyMatchers();
const unansweredCount = questions.length - Object.keys(answers).length;
@@ -232,6 +238,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
Review your answers:
</Text>
</Box>
{preOptionsContent}
{hasUnanswered && (
<Box marginBottom={1}>
@@ -276,6 +283,7 @@ interface TextQuestionViewProps {
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
preOptionsContent?: React.ReactNode;
}
const TextQuestionView: React.FC<TextQuestionViewProps> = ({
@@ -288,6 +296,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
initialAnswer,
progressHeader,
keyboardHints,
preOptionsContent,
}) => {
const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
@@ -367,11 +376,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
maxWidth={availableWidth}
overflowDirection="bottom"
>
<MarkdownDisplay
text={autoBoldIfPlain(question.question)}
terminalWidth={availableWidth - DIALOG_PADDING}
isPending={false}
/>
<Box flexDirection="column">
<MarkdownDisplay
text={autoBoldIfPlain(question.question)}
terminalWidth={availableWidth - DIALOG_PADDING}
isPending={false}
/>
{preOptionsContent}
</Box>
</MaxSizedBox>
</Box>
@@ -496,6 +508,7 @@ interface ChoiceQuestionViewProps {
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
preOptionsContent?: React.ReactNode;
}
const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
@@ -508,6 +521,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
initialAnswer,
progressHeader,
keyboardHints,
preOptionsContent,
}) => {
const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
@@ -889,6 +903,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
(Select all that apply)
</Text>
)}
{preOptionsContent}
</Box>
</MaxSizedBox>
</Box>
@@ -1009,6 +1024,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
width,
availableHeight: availableHeightProp,
extraParts,
preOptionsContent,
}) => {
const keyMatchers = useKeyMatchers();
const uiState = useContext(UIStateContext);
@@ -1206,6 +1222,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onSubmit={handleReviewSubmit}
progressHeader={progressHeader}
extraParts={extraParts}
preOptionsContent={preOptionsContent}
/>
</Box>
);
@@ -1246,6 +1263,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
preOptionsContent={preOptionsContent}
/>
) : (
<ChoiceQuestionView
@@ -1259,6 +1277,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
preOptionsContent={preOptionsContent}
/>
);
@@ -139,17 +139,22 @@ Implement a comprehensive authentication system with multiple providers.
vi.restoreAllMocks();
});
const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => {
const renderDialog = async (options?: {
useAlternateBuffer?: boolean;
diffContent?: string;
availableHeight?: number;
}) => {
const useAlternateBuffer = options?.useAlternateBuffer ?? true;
return renderWithProviders(
<ExitPlanModeDialog
planPath={mockPlanFullPath}
diffContent={options?.diffContent}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
availableHeight={options?.availableHeight ?? 24}
/>,
{
...options,
@@ -200,6 +205,30 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toMatchSnapshot();
});
it('renders diffContent if provided', async () => {
const diffContent =
'--- test-plan.md\n+++ test-plan.md\n@@ -1,1 +1,1 @@\n- old\n+ new';
const { lastFrame } = await act(async () =>
renderDialog({
useAlternateBuffer,
diffContent,
availableHeight: 100,
}),
);
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Changes since previous version');
expect(lastFrame()).toContain('old');
expect(lastFrame()).toContain('new');
});
expect(lastFrame()).toMatchSnapshot();
});
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
const { stdin, lastFrame } = await act(async () =>
renderDialog({ useAlternateBuffer }),
@@ -25,9 +25,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
export interface ExitPlanModeDialogProps {
planPath: string;
diffContent?: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
@@ -140,6 +142,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
planPath,
diffContent,
onApprove,
onFeedback,
onCancel,
@@ -226,6 +229,16 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
const extraParts: React.ReactNode[] = [];
extraParts.push(`${editHint} to edit plan`);
const preOptionsContent = diffContent ? (
<Box key="diff" flexDirection="column" marginTop={1} marginBottom={1}>
<Text bold>Changes since previous version:</Text>
<DiffRenderer diffContent={diffContent} terminalWidth={width} />
</Box>
) : undefined;
return (
<Box flexDirection="column" width={width}>
<AskUserDialog
@@ -264,7 +277,8 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
extraParts={[`${editHint} to edit plan`]}
extraParts={extraParts}
preOptionsContent={preOptionsContent}
/>
</Box>
);
@@ -103,6 +103,38 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders diffContent if provided 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
Changes since previous version:
1 - old
1 + new
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
@@ -246,3 +278,35 @@ Files to Modify
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders diffContent if provided 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
Changes since previous version:
1 - old
1 + new
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
@@ -539,6 +539,7 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
diffContent={confirmationDetails.diffContent}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
@@ -16,7 +16,7 @@ export interface DialogFooterProps {
/** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string;
/** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */
extraParts?: string[];
extraParts?: React.ReactNode[];
}
/**
@@ -29,16 +29,28 @@ export const DialogFooter: React.FC<DialogFooterProps> = ({
cancelAction = 'Esc to cancel',
extraParts = [],
}) => {
const parts = [primaryAction];
const textParts: string[] = [primaryAction];
if (navigationActions) {
parts.push(navigationActions);
textParts.push(navigationActions);
}
parts.push(...extraParts);
parts.push(cancelAction);
// We split string parts and node parts to properly render nodes without forcing them into a single string join
const stringExtras = extraParts.filter(
(p): p is string => typeof p === 'string',
);
const nodeExtras = extraParts.filter((p) => typeof p !== 'string');
textParts.push(...stringExtras);
textParts.push(cancelAction);
return (
<Box marginTop={1}>
<Text color={theme.text.secondary}>{parts.join(' · ')}</Text>
<Box marginTop={1} flexDirection="column">
{nodeExtras.map((part, i) => (
<Box key={i}>{part}</Box>
))}
<Box>
<Text color={theme.text.secondary}>{textParts.join(' · ')}</Text>
</Box>
</Box>
);
};
@@ -137,6 +137,7 @@ export type SerializableConfirmationDetails =
title: string;
systemMessage?: string;
planPath: string;
diffContent?: string;
};
export interface UpdatePolicy {
@@ -141,6 +141,25 @@ describe('ExitPlanModeTool', () => {
expect(executeResult.llmContent).toContain('Plan approved');
});
it('should calculate and return diffContent when a backup exists', async () => {
const planRelativePath = createPlanFile('test-md', '# Current Plan');
createPlanFile('test-md.v1', '# Old Plan');
const invocation = tool.build({ plan_filename: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).not.toBe(false);
if (result === false) return;
expect(result.type).toBe('exit_plan_mode');
if (result.type === 'exit_plan_mode') {
expect(result.diffContent).toContain('Current Plan');
expect(result.diffContent).toContain('Old Plan');
}
});
it('should throw error when policy decision is DENY', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_filename: planRelativePath });
@@ -268,6 +287,28 @@ Revise the plan based on the feedback.`,
});
});
it('should create a backup file when plan is rejected', async () => {
const planRelativePath = createPlanFile('test-backup.md', '# Content');
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback: 'Please change.',
});
await invocation.execute(new AbortController().signal);
const expectedBackupPath = path.join(mockPlansDir, 'test-backup.md.v1');
expect(fs.existsSync(expectedBackupPath)).toBe(true);
expect(fs.readFileSync(expectedBackupPath, 'utf8')).toBe('# Content');
});
it('should handle rejection without feedback gracefully', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_filename: planRelativePath });
+50 -1
View File
@@ -18,7 +18,11 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import {
validatePlanPath,
validatePlanContent,
getPlanVersions,
} from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
import { logPlanExecution } from '../telemetry/loggers.js';
@@ -26,6 +30,10 @@ import { PlanExecutionEvent } from '../telemetry/types.js';
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js';
import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { debugLogger } from '../utils/debugLogger.js';
import * as fsPromises from 'node:fs/promises';
export interface ExitPlanModeParams {
plan_filename: string;
@@ -153,10 +161,40 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
}
// decision is 'ask_user'
let diffContent: string | undefined;
try {
const versions = await getPlanVersions(resolvedPlanPath);
const latestVersion = versions.length > 0 ? Math.max(...versions) : 0;
if (latestVersion > 0) {
const previousPlanPath = `${resolvedPlanPath}.v${latestVersion}`;
const previousPlanContent = await fsPromises.readFile(
previousPlanPath,
'utf8',
);
const currentPlanContent = await fsPromises.readFile(
resolvedPlanPath,
'utf8',
);
diffContent = Diff.createPatch(
path.basename(resolvedPlanPath),
previousPlanContent,
currentPlanContent,
`Previous version (v${latestVersion})`,
'Current version',
DEFAULT_DIFF_OPTIONS,
);
}
} catch (err) {
debugLogger.error('Failed to create diff for plan:', err);
}
return {
type: 'exit_plan_mode',
title: 'Plan Approval',
planPath: resolvedPlanPath,
diffContent,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
@@ -229,6 +267,17 @@ Read and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
};
} else {
try {
const versions = await getPlanVersions(resolvedPlanPath);
const nextVersion =
(versions.length > 0 ? Math.max(...versions) : 0) + 1;
const backupPath = `${resolvedPlanPath}.v${nextVersion}`;
const content = await fsPromises.readFile(resolvedPlanPath, 'utf8');
await fsPromises.writeFile(backupPath, content, 'utf8');
} catch (err) {
debugLogger.error('Failed to create plan backup:', err);
}
const feedback = payload?.feedback?.trim();
if (feedback) {
return {
+1
View File
@@ -1067,6 +1067,7 @@ export interface ToolExitPlanModeConfirmationDetails {
title: string;
systemMessage?: string;
planPath: string;
diffContent?: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
+22 -1
View File
@@ -5,8 +5,10 @@
*/
import path from 'node:path';
import fsPromises from 'node:fs/promises';
import { isEmpty, fileExists } from './fileUtils.js';
import { isSubpath, resolveToRealPath } from './paths.js';
import { debugLogger } from './debugLogger.js';
/**
* Standard error messages for the plan approval workflow.
@@ -41,7 +43,6 @@ export async function validatePlanPath(
if (!isSubpath(realPlansDir, realPath)) {
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
}
if (!(await fileExists(resolvedPath))) {
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
}
@@ -49,6 +50,26 @@ export async function validatePlanPath(
return null;
}
/**
* Returns a list of version numbers for a plan file by scanning the directory.
* @param planPath The path to the plan file.
* @returns A promise that resolves to an array of version numbers.
*/
export async function getPlanVersions(planPath: string): Promise<number[]> {
const dir = path.dirname(planPath);
const base = path.basename(planPath);
try {
const files = await fsPromises.readdir(dir);
return files
.filter((f) => f.startsWith(`${base}.v`))
.map((f) => parseInt(f.slice(base.length + 2), 10))
.filter((v) => !isNaN(v));
} catch (err) {
debugLogger.error(`Failed to read plan versions in ${dir}:`, err);
return [];
}
}
/**
* Validates that a plan file has non-empty content.
* @param planPath The path to the plan file.