diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index 8e8d6a5134e..844251ffb99 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -6,6 +6,17 @@
import { describe, it, expect, vi } from 'vitest';
import { EOL } from 'node:os';
+import { promises as fsp } from 'node:fs';
+
+// Capture launches of the external editor so the full-plan viewer (#7001)
+// can be asserted without spawning a real editor process.
+const { launchEditorMock } = vi.hoisted(() => ({
+ launchEditorMock: vi.fn((_filePath: string) => Promise.resolve()),
+}));
+vi.mock('../../hooks/useLaunchEditor.js', () => ({
+ useLaunchEditor: () => launchEditorMock,
+}));
+
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
@@ -202,6 +213,93 @@ describe('ToolConfirmationMessage', () => {
expect(lastFrame()).toContain('Step one');
});
+ describe('full-plan viewer (#7001)', () => {
+ const plan = [
+ '# Big Plan',
+ ...Array.from({ length: 60 }, (_, i) => `- Step ${i + 1}`),
+ ].join('\n');
+
+ const planDetails = (onConfirm = vi.fn()): ToolCallConfirmationDetails => ({
+ type: 'plan',
+ title: 'Would you like to proceed?',
+ plan,
+ onConfirm,
+ });
+
+ it('shows the open-in-editor hint on plan confirmations', () => {
+ launchEditorMock.mockClear();
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('o open full plan in editor');
+ });
+
+ it('`o` writes the FULL plan to a temp file and opens the editor without confirming', async () => {
+ launchEditorMock.mockClear();
+ const onConfirm = vi.fn();
+ const { stdin } = renderWithProviders(
+ ,
+ );
+
+ stdin.write('o');
+ await vi.waitFor(() => expect(launchEditorMock).toHaveBeenCalledTimes(1));
+
+ const openedPath = launchEditorMock.mock.calls[0]![0];
+ // The staged file must contain the COMPLETE plan, not the truncated view.
+ expect(await fsp.readFile(openedPath, 'utf-8')).toBe(plan);
+ // Viewing the plan must not resolve the confirmation either way.
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('Ctrl+O does not open the editor', async () => {
+ launchEditorMock.mockClear();
+ const { stdin } = renderWithProviders(
+ ,
+ );
+ stdin.write('\x0f'); // Ctrl+O
+ await new Promise((r) => setTimeout(r, 50));
+ expect(launchEditorMock).not.toHaveBeenCalled();
+ });
+
+ it('`o` is inert for non-plan confirmations', async () => {
+ launchEditorMock.mockClear();
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'info',
+ title: 'Confirm Web Fetch',
+ prompt: 'https://example.com',
+ urls: ['https://example.com'],
+ onConfirm: vi.fn(),
+ };
+ const { stdin, lastFrame } = renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).not.toContain('o open full plan in editor');
+ stdin.write('o');
+ await new Promise((r) => setTimeout(r, 50));
+ expect(launchEditorMock).not.toHaveBeenCalled();
+ });
+ });
+
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index a7869c5e76a..2588a229b3a 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -6,6 +6,9 @@
import type React from 'react';
import { useEffect, useState } from 'react';
+import { promises as fs } from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -26,6 +29,7 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
+import { useLaunchEditor } from '../../hooks/useLaunchEditor.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
@@ -103,11 +107,42 @@ export const ToolConfirmationMessage: React.FC<
const isTrustedFolder = config.isTrustedFolder();
+ const launchEditor = useLaunchEditor();
+ const [planViewError, setPlanViewError] = useState(null);
+
+ // #7001: a long plan can exceed the confirmation dialog's height budget and
+ // get truncated (with a "... N more lines not shown ..." cue since #6882),
+ // yet the user is asked to approve it. `o` writes the FULL plan to a temp
+ // file and opens it in the configured editor so the decision is informed;
+ // the dialog stays open (nothing is confirmed) while the user reads.
+ const openFullPlanInEditor = () => {
+ if (confirmationDetails.type !== 'plan') return;
+ setPlanViewError(null);
+ const openPlan = async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-plan-'));
+ const planPath = path.join(dir, 'plan.md');
+ await fs.writeFile(planPath, confirmationDetails.plan);
+ await launchEditor(planPath);
+ };
+ void openPlan().catch((err: unknown) => {
+ setPlanViewError(err instanceof Error ? err.message : String(err));
+ });
+ };
+
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
+ return;
+ }
+ if (
+ key.name === 'o' &&
+ !key.ctrl &&
+ !key.meta &&
+ confirmationDetails.type === 'plan'
+ ) {
+ openFullPlanInEditor();
}
},
{ isActive: isFocused },
@@ -347,12 +382,15 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.Cancel,
});
- const planHeight = compactMode
+ // Reserve one row for the "o open full plan" hint below the plan body.
+ const rawPlanHeight = compactMode
? Math.min(
availableBodyContentHeight() ?? COMPACT_BODY_MAX_LINES,
COMPACT_BODY_MAX_LINES,
)
: availableBodyContentHeight();
+ const planHeight =
+ rawPlanHeight === undefined ? undefined : Math.max(rawPlanHeight - 1, 1);
bodyContent = (
+ {/* The plan can be height-truncated above, and the user is about to
+ approve it — always offer a way to read the WHOLE thing. See #7001. */}
+ {planViewError ? (
+
+ {planViewError}
+
+ ) : (
+
+ {t('o open full plan in editor')}
+
+ )}
);
} else if (confirmationDetails.type === 'info') {