Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
116 changes: 104 additions & 12 deletions packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@ import { describe, it, expect, vi } from 'vitest';
import { EOL } from 'node:os';
import { promises as fsp } from 'node:fs';
import { Box } from 'ink';
import { render } from 'ink-testing-library';

// 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(() => ({
const { launchEditorMock, isEditorAvailableMock } = vi.hoisted(() => ({
launchEditorMock: vi.fn((_filePath: string) => Promise.resolve()),
isEditorAvailableMock: vi.fn((editor: string | undefined) => Boolean(editor)),
}));
vi.mock('../../hooks/useLaunchEditor.js', () => ({
useLaunchEditor: () => launchEditorMock,
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@qwen-code/qwen-code-core')
>();
return {
...actual,
isEditorAvailable: isEditorAvailableMock,
};
});

import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
Expand All @@ -26,6 +37,8 @@ import type {
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';

describe('ToolConfirmationMessage', () => {
const mockConfig = {
Expand Down Expand Up @@ -591,12 +604,57 @@ describe('ToolConfirmationMessage', () => {
newContent: 'b',
onConfirm: vi.fn(),
};
const execConfirmationDetails: ToolCallConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'echo hello',
rootCommand: 'echo',
onConfirm: vi.fn(),
};
const preferredEditorSettings = {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings;

it('should show "Modify with external editor" when preferredEditor is set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
isEditorAvailableMock.mockClear();

const props = {
confirmationDetails: editConfirmationDetails,
config: mockConfig,
contentWidth: 80,
};
const tree = (height: number) => (
<SettingsContext.Provider value={preferredEditorSettings}>
<KeypressProvider kittyProtocolEnabled={true}>
<ToolConfirmationMessage
{...props}
availableTerminalHeight={height}
/>
</KeypressProvider>
</SettingsContext.Provider>
);
const { lastFrame, rerender } = render(tree(30));

expect(lastFrame()).toContain('Modify with external editor');
expect(isEditorAvailableMock).toHaveBeenCalledWith('vscode');
expect(isEditorAvailableMock).toHaveBeenCalledTimes(1);

rerender(tree(31));
expect(lastFrame()).toContain('Modify with external editor');
expect(isEditorAvailableMock).toHaveBeenCalledTimes(1);
});

it('should NOT show "Modify with external editor" when the configured editor is unavailable', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
isEditorAvailableMock.mockClear();
isEditorAvailableMock.mockReturnValueOnce(false);

const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
Expand All @@ -605,21 +663,20 @@ describe('ToolConfirmationMessage', () => {
availableTerminalHeight={30}
contentWidth={80}
/>,
{
settings: {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings,
},
{ settings: preferredEditorSettings },
);

expect(lastFrame()).toContain('Modify with external editor');
expect(lastFrame()).toContain('Yes, allow once');
expect(isEditorAvailableMock).toHaveBeenCalledWith('vscode');
expect(lastFrame()).not.toContain('Modify with external editor');
Comment thread
SLP-DEV1 marked this conversation as resolved.
});

it('should NOT show "Modify with external editor" when preferredEditor is not set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
isEditorAvailableMock.mockClear();

const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
Expand All @@ -635,6 +692,7 @@ describe('ToolConfirmationMessage', () => {
},
);

expect(isEditorAvailableMock).not.toHaveBeenCalled();
expect(lastFrame()).not.toContain('Modify with external editor');
});

Expand All @@ -643,6 +701,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
isEditorAvailableMock.mockClear();

const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
Expand All @@ -651,16 +710,49 @@ describe('ToolConfirmationMessage', () => {
availableTerminalHeight={30}
contentWidth={80}
/>,
{
settings: {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings,
},
{ settings: preferredEditorSettings },
);

expect(isEditorAvailableMock).not.toHaveBeenCalled();
expect(lastFrame()).not.toContain('Modify with external editor');
});

it('should NOT probe editor availability in compactMode', () => {
isEditorAvailableMock.mockClear();

const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
contentWidth={80}
compactMode={true}
/>,
{ settings: preferredEditorSettings },
);

expect(lastFrame()).toContain('Yes, allow once');
expect(isEditorAvailableMock).not.toHaveBeenCalled();
expect(lastFrame()).not.toContain('Modify with external editor');
});

it('should NOT probe editor availability for a non-edit confirmation', () => {
isEditorAvailableMock.mockClear();

const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={execConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
contentWidth={80}
/>,
{ settings: preferredEditorSettings },
);

expect(lastFrame()).toContain('Yes, allow once');
expect(isEditorAvailableMock).not.toHaveBeenCalled();
});

it('renders edit warnings and honors hideAlwaysAllow on small terminals', () => {
const mockConfig = {
isTrustedFolder: () => true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type React from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
Expand All @@ -25,6 +25,7 @@ import {
IdeClient,
ToolConfirmationOutcome,
buildHumanReadableRuleLabel,
isEditorAvailable,
} from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
Expand Down Expand Up @@ -67,6 +68,19 @@ export const ToolConfirmationMessage: React.FC<
const preferredEditor = settings.merged.general?.preferredEditor as
| EditorType
| undefined;
const hideModify =
confirmationDetails.type === 'edit'
? confirmationDetails.hideModify
: false;
const editorAvailable = useMemo(
() =>
!compactMode &&
confirmationDetails.type === 'edit' &&
Comment thread
SLP-DEV1 marked this conversation as resolved.
!hideModify &&
preferredEditor !== undefined &&
isEditorAvailable(preferredEditor),
[compactMode, confirmationDetails.type, hideModify, preferredEditor],
);
Comment thread
SLP-DEV1 marked this conversation as resolved.

const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
Expand Down Expand Up @@ -259,7 +273,7 @@ export const ToolConfirmationMessage: React.FC<
if (
!confirmationDetails.hideModify &&
(!config.getIdeMode() || !isDiffingEnabled) &&
preferredEditor
editorAvailable
) {
options.push({
label: t('Modify with external editor'),
Expand Down
Loading