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:
A.K.M. Adib
2026-03-31 17:15:41 -04:00
parent 9cf410478c
commit b6a6682f26
11 changed files with 193 additions and 15 deletions
@@ -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>
);
};