Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/cli/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@ structure, and consultation level are proportional to the task's complexity:
- **Iterate:** Provide feedback to refine the plan.
- **Refine manually:** Press **Ctrl + X** to open the plan file in your
[preferred external editor]. This allows you to manually refine the plan
steps before approval. The CLI will automatically refresh and show the
updated plan after you save and close the editor.
steps before approval. If you make any changes and save the file, the CLI
will automatically send the updated plan back to the agent for review and
iteration.

For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills).
Expand Down
53 changes: 39 additions & 14 deletions packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import {
ApprovalMode,
validatePlanContent,
processSingleFileContent,
type FileSystemService,
readFileLines,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';

Expand All @@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
validatePlanPath: vi.fn(async () => null),
validatePlanContent: vi.fn(async () => null),
processSingleFileContent: vi.fn(),
readFileLines: vi.fn(),
};
});

Expand All @@ -41,10 +42,6 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
existsSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});

Expand Down Expand Up @@ -131,6 +128,7 @@ Implement a comprehensive authentication system with multiple providers.
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
vi.mocked(readFileLines).mockResolvedValue(samplePlanContent.split('\n'));
onApprove = vi.fn();
onFeedback = vi.fn();
onCancel = vi.fn();
Expand Down Expand Up @@ -546,7 +544,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled();
});

it('opens plan in external editor when Ctrl+X is pressed', async () => {
it('automatically submits feedback if plan was edited via Ctrl+X', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });

await act(async () => {
Expand All @@ -557,25 +555,52 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication');
});

// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();
// Mock different content for second read
vi.mocked(readFileLines)
.mockResolvedValueOnce(samplePlanContent.split('\n'))
.mockResolvedValueOnce([
...samplePlanContent.split('\n'),
'# Added feedback',
]);

// Press Ctrl+X
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
});

await waitFor(() => {
expect(openFileInEditor).toHaveBeenCalledWith(
mockPlanFullPath,
expect.anything(),
expect.anything(),
undefined,
expect(onFeedback).toHaveBeenCalledWith(
'I have annotated the plan with feedback. Please review the edited plan file and update the plan accordingly.',
);
});
});

it('does not submit feedback if plan was not edited via Ctrl+X', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });

await act(async () => {
vi.runAllTimers();
});

await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});

// Mock same content for both reads
vi.mocked(readFileLines).mockResolvedValue(
samplePlanContent.split('\n'),
);

// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();

// Press Ctrl+X
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
});

// Verify that content is refreshed (processSingleFileContent called again)
await waitFor(() => {
expect(onFeedback).not.toHaveBeenCalled();
expect(processSingleFileContent).toHaveBeenCalled();
});
});
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type EditorType,
processSingleFileContent,
debugLogger,
readFileLines,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
Expand Down Expand Up @@ -155,12 +156,21 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({

const handleOpenEditor = useCallback(async () => {
try {
const beforeLines = await readFileLines(planPath);
await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
refresh();
const afterLines = await readFileLines(planPath);

if (JSON.stringify(beforeLines) !== JSON.stringify(afterLines)) {
onFeedback(
'I have annotated the plan with feedback. Please review the edited plan file and update the plan accordingly.',
);
} else {
refresh();
}
} catch (err) {
debugLogger.error('Failed to open plan in editor:', err);
}
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh]);
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);

useKeypress(
(key) => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export * from './tools/mcp-tool.js';
export * from './tools/write-todos.js';
export * from './tools/activate-skill.js';
export * from './tools/ask-user.js';
export * from './tools/grep-utils.js';

// MCP OAuth
export { MCPOAuthProvider } from './mcp/oauth-provider.js';
Expand Down
Loading