From b6a6682f267d9965f394686d8694c29e66e969ee Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 31 Mar 2026 17:15:41 -0400 Subject: [PATCH] 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 --- docs/tools/planning.md | 3 ++ package-lock.json | 34 ++++++++++-- .../cli/src/ui/components/AskUserDialog.tsx | 4 +- .../ui/components/ExitPlanModeDialog.test.tsx | 31 ++++++++++- .../src/ui/components/ExitPlanModeDialog.tsx | 16 +++++- .../messages/ToolConfirmationMessage.tsx | 1 + .../src/ui/components/shared/DialogFooter.tsx | 24 ++++++--- packages/core/src/confirmation-bus/types.ts | 1 + .../core/src/tools/exit-plan-mode.test.ts | 41 +++++++++++++++ packages/core/src/tools/exit-plan-mode.ts | 52 +++++++++++++++++++ packages/core/src/tools/tools.ts | 1 + 11 files changed, 193 insertions(+), 15 deletions(-) diff --git a/docs/tools/planning.md b/docs/tools/planning.md index e554e47a34..eabbbe250c 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -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. diff --git a/package-lock.json b/package-lock.json index f3bf8fa616..51feb54865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -486,7 +486,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1489,6 +1490,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -2195,6 +2197,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2375,6 +2378,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2424,6 +2428,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2798,6 +2803,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2831,6 +2837,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2885,6 +2892,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4121,6 +4129,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4395,6 +4404,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5268,6 +5278,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7402,7 +7413,8 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7986,6 +7998,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8503,6 +8516,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9815,6 +9829,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10093,6 +10108,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz", "integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13851,6 +13867,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13861,6 +13878,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16010,6 +16028,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16232,7 +16251,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16240,6 +16260,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16405,6 +16426,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16627,6 +16649,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16740,6 +16763,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16752,6 +16776,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17399,6 +17424,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17842,6 +17868,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -17945,6 +17972,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 483fcb5055..b1af18040f 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -190,7 +190,7 @@ interface AskUserDialogProps { /** * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */ - extraParts?: string[]; + extraParts?: React.ReactNode[]; } interface ReviewViewProps { @@ -198,7 +198,7 @@ interface ReviewViewProps { answers: { [key: string]: string }; onSubmit: () => void; progressHeader?: React.ReactNode; - extraParts?: string[]; + extraParts?: React.ReactNode[]; } const ReviewView: React.FC = ({ diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index d6fc23dd70..f9734c4dad 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -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( , { ...options, @@ -200,6 +205,28 @@ 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'); + }); + }); + it('calls onApprove with AUTO_EDIT when first option is selected', async () => { const { stdin, lastFrame } = await act(async () => renderDialog({ useAlternateBuffer }), diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index b2c28abaeb..062592a806 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -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 = ({ planPath, + diffContent, onApprove, onFeedback, onCancel, @@ -226,6 +229,17 @@ export const ExitPlanModeDialog: React.FC = ({ const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR); + const extraParts: React.ReactNode[] = []; + if (diffContent) { + extraParts.push( + + Changes since previous version: + + + ); + } + extraParts.push(`${editHint} to edit plan`); + return ( = ({ onCancel={onCancel} width={width} availableHeight={availableHeight} - extraParts={[`${editHint} to edit plan`]} + extraParts={extraParts} /> ); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 631bbf032d..8a37d731bf 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -539,6 +539,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx index ee16d43650..0e4a0beef3 100644 --- a/packages/cli/src/ui/components/shared/DialogFooter.tsx +++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx @@ -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,26 @@ export const DialogFooter: React.FC = ({ 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 ( - - {parts.join(' · ')} + + {nodeExtras.map((part, i) => ( + {part} + ))} + + {textParts.join(' · ')} + ); }; diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index c47a1c1cf5..314b0f7f12 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -137,6 +137,7 @@ export type SerializableConfirmationDetails = title: string; systemMessage?: string; planPath: string; + diffContent?: string; }; export interface UpdatePolicy { diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index ad643c6cb2..7e10bc8377 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -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 }); diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index 483b1e5f3d..85f936ee80 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -26,6 +26,11 @@ 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 fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; export interface ExitPlanModeParams { plan_filename: string; @@ -153,10 +158,44 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< } // decision is 'ask_user' + let diffContent: string | undefined; + try { + let latestVersion = 0; + let version = 1; + while (fs.existsSync(`${resolvedPlanPath}.v${version}`)) { + latestVersion = version; + version++; + } + + 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 +268,19 @@ Read and follow the plan strictly during implementation.`, returnDisplay: `Plan approved: ${resolvedPlanPath}`, }; } else { + try { + let version = 1; + let backupPath = `${resolvedPlanPath}.v${version}`; + while (fs.existsSync(backupPath)) { + version++; + backupPath = `${resolvedPlanPath}.v${version}`; + } + 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 { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 23e88b608b..8dce991615 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -1067,6 +1067,7 @@ export interface ToolExitPlanModeConfirmationDetails { title: string; systemMessage?: string; planPath: string; + diffContent?: string; onConfirm: ( outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload,