mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+31
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<ReviewViewProps> = ({
|
||||
|
||||
@@ -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,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 }),
|
||||
|
||||
@@ -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,17 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
|
||||
const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
|
||||
|
||||
const extraParts: React.ReactNode[] = [];
|
||||
if (diffContent) {
|
||||
extraParts.push(
|
||||
<Box key="diff" flexDirection="column" marginTop={1}>
|
||||
<Text bold>Changes since previous version:</Text>
|
||||
<DiffRenderer diffContent={diffContent} terminalWidth={width} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
extraParts.push(`${editHint} to edit plan`);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<AskUserDialog
|
||||
@@ -264,7 +278,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
availableHeight={availableHeight}
|
||||
extraParts={[`${editHint} to edit plan`]}
|
||||
extraParts={extraParts}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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,26 @@ 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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export interface ToolExitPlanModeConfirmationDetails {
|
||||
title: string;
|
||||
systemMessage?: string;
|
||||
planPath: string;
|
||||
diffContent?: string;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
|
||||
Reference in New Issue
Block a user