mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 10:00:53 -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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user