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') {