mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feature/arena-agent-collaboration
This commit is contained in:
commit
74b342623c
172 changed files with 12390 additions and 3258 deletions
|
|
@ -24,7 +24,7 @@ const CODING_PLAN_API_KEY_URL =
|
|||
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
|
||||
|
||||
const CODING_PLAN_INTL_API_KEY_URL =
|
||||
'https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/api_key';
|
||||
'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan';
|
||||
|
||||
export function ApiKeyInput({
|
||||
onSubmit,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import process from 'node:process';
|
|||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
|
@ -282,9 +281,6 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isVisionSwitchDialogOpen) {
|
||||
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
|
||||
}
|
||||
|
||||
if (uiState.isAuthDialogOpen || uiState.authError) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { SkillsList } from './views/SkillsList.js';
|
|||
import { ToolsList } from './views/ToolsList.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
||||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
|
|
@ -207,6 +208,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
width={boxWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'insight_progress' && (
|
||||
<InsightProgressMessage progress={itemForDisplay.progress} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||
|
|
@ -378,10 +380,37 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
// Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16)
|
||||
const describeConditional = isWindows ? it.skip : it;
|
||||
describeConditional(
|
||||
'should handle Ctrl+V when clipboard has an image',
|
||||
async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/Users/mochi/.qwen/tmp/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle Cmd+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.qwen-clipboard/clipboard-123.png',
|
||||
'/Users/mochi/.qwen/tmp/clipboard-456.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
|
|
@ -389,18 +418,15 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Send Cmd+V (meta key) / Alt+V on Windows
|
||||
// In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v'
|
||||
stdin.write('\x1Bv');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -412,7 +438,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
|
|
@ -430,7 +457,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
|
|
@ -439,11 +467,7 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.qwen-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png';
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
|
||||
|
||||
|
|
@ -451,27 +475,20 @@ describe('InputPrompt', () => {
|
|||
mockBuffer.text = 'Hello world';
|
||||
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
|
||||
mockBuffer.lines = ['Hello world'];
|
||||
mockBuffer.replaceRangeByOffset = vi.fn();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should insert at cursor position with spaces
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
|
||||
// Get the actual call to see what path was used
|
||||
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
|
||||
.calls[0];
|
||||
expect(actualCall[0]).toBe(5); // start offset
|
||||
expect(actualCall[1]).toBe(5); // end offset
|
||||
expect(actualCall[2]).toBe(
|
||||
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
|
||||
);
|
||||
// The new implementation adds images as attachments rather than inserting into buffer
|
||||
// So we verify that saveClipboardImage was called instead
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -485,7 +502,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should not throw and should not set buffer text on error
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
Storage,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
buildSegmentsForVisualSlice,
|
||||
|
|
@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
|||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
|
||||
/**
|
||||
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string; // Unique identifier (timestamp)
|
||||
path: string; // Full file path
|
||||
filename: string; // Filename only (for display)
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('INPUT_PROMPT');
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
|
|
@ -126,6 +139,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Attachment state for clipboard images
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isAttachmentMode, setIsAttachmentMode] = useState(false);
|
||||
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
|
||||
// Large paste placeholder handling
|
||||
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
|
|
@ -281,10 +298,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (shellModeActive) {
|
||||
shellHistory.addCommandToHistory(finalValue);
|
||||
}
|
||||
|
||||
// Convert attachments to @references and prepend to the message
|
||||
if (attachments.length > 0) {
|
||||
const attachmentRefs = attachments
|
||||
.map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
|
||||
.join(' ');
|
||||
finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`;
|
||||
}
|
||||
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
onSubmit(finalValue);
|
||||
|
||||
// Clear attachments after submit
|
||||
setAttachments([]);
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
|
|
@ -295,6 +327,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
attachments,
|
||||
config,
|
||||
pendingPastes,
|
||||
],
|
||||
);
|
||||
|
|
@ -336,52 +370,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
]);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
const handleClipboardImage = useCallback(async (validated = false) => {
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
const hasImage = validated || (await clipboardHasImage());
|
||||
if (hasImage) {
|
||||
const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
|
||||
if (imagePath) {
|
||||
// Clean up old images
|
||||
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||||
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
});
|
||||
|
||||
// Get relative path from current directory
|
||||
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||||
|
||||
// Insert @path reference at cursor position
|
||||
const insertText = `@${relativePath}`;
|
||||
const currentText = buffer.text;
|
||||
const [row, col] = buffer.cursor;
|
||||
|
||||
// Calculate offset from row/col
|
||||
let offset = 0;
|
||||
for (let i = 0; i < row; i++) {
|
||||
offset += buffer.lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
offset += col;
|
||||
|
||||
// Add spaces around the path if needed
|
||||
let textToInsert = insertText;
|
||||
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||||
const charAfter =
|
||||
offset < currentText.length ? currentText[offset] : '';
|
||||
|
||||
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||||
textToInsert = ' ' + textToInsert;
|
||||
}
|
||||
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||||
textToInsert = textToInsert + ' ';
|
||||
}
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
// Add as attachment instead of inserting @reference into text
|
||||
const filename = path.basename(imagePath);
|
||||
const newAttachment: Attachment = {
|
||||
id: String(Date.now()),
|
||||
path: imagePath,
|
||||
filename,
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling clipboard image:', error);
|
||||
}
|
||||
}, [buffer, config]);
|
||||
}, []);
|
||||
|
||||
// Handle deletion of an attachment from the list
|
||||
const handleAttachmentDelete = useCallback((index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const newList = prev.filter((_, i) => i !== index);
|
||||
if (newList.length === 0) {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
} else {
|
||||
setSelectedAttachmentIndex(Math.min(index, newList.length - 1));
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
|
|
@ -412,7 +439,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const charCount = [...pasted].length; // Proper Unicode char count
|
||||
const lineCount = pasted.split('\n').length;
|
||||
if (
|
||||
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
if (key.pasteImage) {
|
||||
handleClipboardImage(true);
|
||||
} else if (
|
||||
charCount > LARGE_PASTE_CHAR_THRESHOLD ||
|
||||
lineCount > LARGE_PASTE_LINE_THRESHOLD
|
||||
) {
|
||||
|
|
@ -666,6 +697,55 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Attachment mode handling - process before history navigation
|
||||
if (isAttachmentMode && attachments.length > 0) {
|
||||
if (key.name === 'left') {
|
||||
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right') {
|
||||
setSelectedAttachmentIndex((i) =>
|
||||
Math.min(attachments.length - 1, i + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
// Exit attachment mode and return to input
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
handleAttachmentDelete(selectedAttachmentIndex);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return' || key.name === 'escape') {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
// For other keys, exit attachment mode and let input handle them
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
// Continue to process the key in input
|
||||
}
|
||||
|
||||
// Enter attachment mode when pressing up at the first line with attachments
|
||||
if (
|
||||
!isAttachmentMode &&
|
||||
attachments.length > 0 &&
|
||||
!shellModeActive &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive &&
|
||||
buffer.visualCursor[0] === 0 &&
|
||||
buffer.visualScrollRow === 0 &&
|
||||
keyMatchers[Command.NAVIGATION_UP](key)
|
||||
) {
|
||||
setIsAttachmentMode(true);
|
||||
setSelectedAttachmentIndex(attachments.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
|
|
@ -864,6 +944,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
isAttachmentMode,
|
||||
attachments,
|
||||
selectedAttachmentIndex,
|
||||
handleAttachmentDelete,
|
||||
uiActions,
|
||||
pasteWorkaround,
|
||||
nextLargePastePlaceholder,
|
||||
|
|
@ -923,6 +1007,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{attachments.length > 0 && (
|
||||
<Box marginLeft={2} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{t('Attachments: ')}</Text>
|
||||
{attachments.map((att, idx) => (
|
||||
<Text
|
||||
key={att.id}
|
||||
color={
|
||||
isAttachmentMode && idx === selectedAttachmentIndex
|
||||
? theme.status.success
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{att.filename}]{idx < attachments.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
|
|
@ -1079,6 +1180,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Attachment hints - show when there are attachments and no suggestions visible */}
|
||||
{attachments.length > 0 && !shouldShowSuggestions && (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{isAttachmentMode
|
||||
? t('← → select, Delete to remove, ↓ to exit')
|
||||
: t('↑ to manage attachments')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ interface Shortcut {
|
|||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getPasteKey = () => {
|
||||
if (process.platform === 'win32') return 'alt+v';
|
||||
return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v';
|
||||
};
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel
|
|||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_CODER,
|
||||
MAINLINE_VLM,
|
||||
} from '../models/availableModels.js';
|
||||
import { getFilteredQwenModels } from '../models/availableModels.js';
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
|
|
@ -29,6 +25,19 @@ const mockedUseKeypress = vi.mocked(useKeypress);
|
|||
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
|
||||
DescriptiveRadioButtonSelect: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// Helper to create getAvailableModelsForAuthType mock
|
||||
const createMockGetAvailableModelsForAuthType = () =>
|
||||
vi.fn((t: AuthType) => {
|
||||
if (t === AuthType.QWEN_OAUTH) {
|
||||
return getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
|
||||
|
||||
const renderComponent = (
|
||||
|
|
@ -49,12 +58,12 @@ const renderComponent = (
|
|||
|
||||
const mockConfig = {
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => MAINLINE_CODER),
|
||||
getModel: vi.fn(() => DEFAULT_QWEN_MODEL),
|
||||
setModel: vi.fn().mockResolvedValue(undefined),
|
||||
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||
getAllConfiguredModels: vi.fn(() =>
|
||||
AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
description: m.description || '',
|
||||
|
|
@ -68,7 +77,7 @@ const renderComponent = (
|
|||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
})),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
|
|
@ -116,24 +125,34 @@ describe('<ModelDialog />', () => {
|
|||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||
expect(props.items).toHaveLength(getFilteredQwenModels().length);
|
||||
// coder-model is the only model and it has vision capability
|
||||
expect(props.items[0].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
||||
);
|
||||
expect(props.items[1].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
||||
`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`,
|
||||
);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes with the model from ConfigContext', () => {
|
||||
const mockGetModel = vi.fn(() => MAINLINE_VLM);
|
||||
renderComponent({}, { getModel: mockGetModel });
|
||||
const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL);
|
||||
renderComponent(
|
||||
{},
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAvailableModelsForAuthType:
|
||||
createMockGetAvailableModelsForAuthType(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
// Calculate expected index dynamically based on model list
|
||||
const qwenModels = getFilteredQwenModels();
|
||||
const expectedIndex = qwenModels.findIndex(
|
||||
(m) => m.id === DEFAULT_QWEN_MODEL,
|
||||
);
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 1,
|
||||
initialIndex: expectedIndex,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -151,14 +170,19 @@ describe('<ModelDialog />', () => {
|
|||
});
|
||||
|
||||
it('initializes with default coder model if getModel returns undefined', () => {
|
||||
const mockGetModel = vi.fn(() => undefined);
|
||||
// @ts-expect-error This test validates component robustness when getModel
|
||||
// returns an unexpected undefined value.
|
||||
renderComponent({}, { getModel: mockGetModel });
|
||||
const mockGetModel = vi.fn(() => undefined as unknown as string);
|
||||
renderComponent(
|
||||
{},
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAvailableModelsForAuthType:
|
||||
createMockGetAvailableModelsForAuthType(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
|
||||
// When getModel returns undefined, preferredModel falls back to MAINLINE_CODER
|
||||
// When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL
|
||||
// which has index 0, so initialIndex should be 0
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -170,22 +194,36 @@ describe('<ModelDialog />', () => {
|
|||
});
|
||||
|
||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
const { props, mockConfig, mockSettings } = renderComponent(
|
||||
{},
|
||||
{
|
||||
getAvailableModelsForAuthType: vi.fn((t: AuthType) => {
|
||||
if (t === AuthType.QWEN_OAUTH) {
|
||||
return getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`);
|
||||
|
||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
|
|
@ -203,7 +241,7 @@ describe('<ModelDialog />', () => {
|
|||
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
|
||||
}
|
||||
if (t === AuthType.QWEN_OAUTH) {
|
||||
return AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
return getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
|
|
@ -217,7 +255,7 @@ describe('<ModelDialog />', () => {
|
|||
getModel: vi.fn(() => 'gpt-4'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
model: DEFAULT_QWEN_MODEL,
|
||||
})),
|
||||
// Add switchModel to the mock object (not the type)
|
||||
switchModel,
|
||||
|
|
@ -231,17 +269,17 @@ describe('<ModelDialog />', () => {
|
|||
);
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`);
|
||||
|
||||
expect(switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
{ requireCachedCredentials: true },
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
|
|
@ -290,7 +328,7 @@ describe('<ModelDialog />', () => {
|
|||
});
|
||||
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||
const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL);
|
||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
|
|
@ -305,8 +343,10 @@ describe('<ModelDialog />', () => {
|
|||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
getAvailableModelsForAuthType:
|
||||
createMockGetAvailableModelsForAuthType(),
|
||||
getAllConfiguredModels: vi.fn(() =>
|
||||
AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
description: m.description || '',
|
||||
|
|
@ -321,14 +361,16 @@ describe('<ModelDialog />', () => {
|
|||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// DEFAULT_QWEN_MODEL (coder-model) is at index 0
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
|
||||
mockGetModel.mockReturnValue(MAINLINE_VLM);
|
||||
mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL);
|
||||
const newMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(),
|
||||
getAllConfiguredModels: vi.fn(() =>
|
||||
AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
getFilteredQwenModels().map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
description: m.description || '',
|
||||
|
|
@ -347,6 +389,11 @@ describe('<ModelDialog />', () => {
|
|||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1);
|
||||
// Calculate expected index for DEFAULT_QWEN_MODEL dynamically
|
||||
const qwenModels = getFilteredQwenModels();
|
||||
const expectedCoderIndex = qwenModels.findIndex(
|
||||
(m) => m.id === DEFAULT_QWEN_MODEL,
|
||||
);
|
||||
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
AuthType,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
MAINLINE_CODER_MODEL,
|
||||
type AvailableModel as CoreAvailableModel,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSource,
|
||||
|
|
@ -22,7 +23,6 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel
|
|||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { MAINLINE_CODER } from '../models/availableModels.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -293,7 +293,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||
[availableModelEntries],
|
||||
);
|
||||
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL;
|
||||
// Check if current model is a runtime model
|
||||
// Runtime snapshot ID is already in $runtime|${authType}|${modelId} format
|
||||
const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.();
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js';
|
||||
|
||||
// Mock the useKeypress hook
|
||||
const mockUseKeypress = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: mockUseKeypress,
|
||||
}));
|
||||
|
||||
// Mock the RadioButtonSelect component
|
||||
const mockRadioButtonSelect = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: mockRadioButtonSelect,
|
||||
}));
|
||||
|
||||
describe('ModelSwitchDialog', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock RadioButtonSelect to return a simple div
|
||||
mockRadioButtonSelect.mockReturnValue(
|
||||
React.createElement('div', { 'data-testid': 'radio-select' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup RadioButtonSelect with correct options', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const expectedItems = [
|
||||
{
|
||||
key: 'switch-once',
|
||||
label: 'Switch for this request only',
|
||||
value: VisionSwitchOutcome.SwitchOnce,
|
||||
},
|
||||
{
|
||||
key: 'switch-session',
|
||||
label: 'Switch session to vision model',
|
||||
value: VisionSwitchOutcome.SwitchSessionToVL,
|
||||
},
|
||||
{
|
||||
key: 'continue',
|
||||
label: 'Continue with current model',
|
||||
value: VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
},
|
||||
];
|
||||
|
||||
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
|
||||
expect(callArgs.items).toEqual(expectedItems);
|
||||
expect(callArgs.initialIndex).toBe(0);
|
||||
expect(callArgs.isFocused).toBe(true);
|
||||
});
|
||||
|
||||
it('should call onSelect when an option is selected', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
|
||||
expect(typeof callArgs.onSelect).toBe('function');
|
||||
|
||||
// Simulate selection of "Switch for this request only"
|
||||
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce);
|
||||
});
|
||||
|
||||
it('should call onSelect with SwitchSessionToVL when second option is selected', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
VisionSwitchOutcome.SwitchSessionToVL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Simulate escape key press
|
||||
const keypressHandler = mockUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call onSelect for non-escape keys', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const keypressHandler = mockUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'enter' });
|
||||
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set initial index to 0 (first option)', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
|
||||
expect(callArgs.initialIndex).toBe(0);
|
||||
});
|
||||
|
||||
describe('VisionSwitchOutcome enum', () => {
|
||||
it('should have correct enum values', () => {
|
||||
expect(VisionSwitchOutcome.SwitchOnce).toBe('once');
|
||||
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session');
|
||||
expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple onSelect calls correctly', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
|
||||
// Call multiple times
|
||||
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
|
||||
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
|
||||
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnSelect).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
VisionSwitchOutcome.SwitchOnce,
|
||||
);
|
||||
expect(mockOnSelect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
VisionSwitchOutcome.SwitchSessionToVL,
|
||||
);
|
||||
expect(mockOnSelect).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass isFocused prop to RadioButtonSelect', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
|
||||
expect(callArgs.isFocused).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle escape key multiple times', () => {
|
||||
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
|
||||
|
||||
const keypressHandler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
// Call escape multiple times
|
||||
keypressHandler({ name: 'escape' });
|
||||
keypressHandler({ name: 'escape' });
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(2);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
export enum VisionSwitchOutcome {
|
||||
SwitchOnce = 'once',
|
||||
SwitchSessionToVL = 'session',
|
||||
ContinueWithCurrentModel = 'persist',
|
||||
}
|
||||
|
||||
export interface ModelSwitchDialogProps {
|
||||
onSelect: (outcome: VisionSwitchOutcome) => void;
|
||||
}
|
||||
|
||||
export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(VisionSwitchOutcome.ContinueWithCurrentModel);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<VisionSwitchOutcome>> = [
|
||||
{
|
||||
key: 'switch-once',
|
||||
label: 'Switch for this request only',
|
||||
value: VisionSwitchOutcome.SwitchOnce,
|
||||
},
|
||||
{
|
||||
key: 'switch-session',
|
||||
label: 'Switch session to vision model',
|
||||
value: VisionSwitchOutcome.SwitchSessionToVL,
|
||||
},
|
||||
{
|
||||
key: 'continue',
|
||||
label: 'Continue with current model',
|
||||
value: VisionSwitchOutcome.ContinueWithCurrentModel,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (outcome: VisionSwitchOutcome) => {
|
||||
onSelect(outcome);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Vision Model Switch Required</Text>
|
||||
<Text>
|
||||
Your message contains an image, but the current model doesn't
|
||||
support vision.
|
||||
</Text>
|
||||
<Text>How would you like to proceed?</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
initialIndex={0}
|
||||
onSelect={handleSelect}
|
||||
isFocused
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>Press Enter to select, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
62
packages/cli/src/ui/components/Tips.test.ts
Normal file
62
packages/cli/src/ui/components/Tips.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { selectWeightedTip } from './Tips.js';
|
||||
|
||||
describe('selectWeightedTip', () => {
|
||||
const tips = [
|
||||
{ text: 'tip-a', weight: 1 },
|
||||
{ text: 'tip-b', weight: 3 },
|
||||
{ text: 'tip-c', weight: 1 },
|
||||
];
|
||||
|
||||
it('returns a valid tip text', () => {
|
||||
const result = selectWeightedTip(tips);
|
||||
expect(['tip-a', 'tip-b', 'tip-c']).toContain(result);
|
||||
});
|
||||
|
||||
it('selects the first tip when random is near zero', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
expect(selectWeightedTip(tips)).toBe('tip-a');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('selects the weighted tip when random falls in its range', () => {
|
||||
// Total weight = 5. tip-a covers [0,1), tip-b covers [1,4), tip-c covers [4,5)
|
||||
// Math.random() * 5 = 2.0 falls in tip-b's range
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.4); // 0.4 * 5 = 2.0
|
||||
expect(selectWeightedTip(tips)).toBe('tip-b');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('selects the last tip when random is near max', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.99);
|
||||
expect(selectWeightedTip(tips)).toBe('tip-c');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('respects weight distribution over many samples', () => {
|
||||
const counts: Record<string, number> = {
|
||||
'tip-a': 0,
|
||||
'tip-b': 0,
|
||||
'tip-c': 0,
|
||||
};
|
||||
const iterations = 10000;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const result = selectWeightedTip(tips);
|
||||
counts[result]!++;
|
||||
}
|
||||
// tip-b (weight 3) should appear roughly 3x as often as tip-a or tip-c (weight 1)
|
||||
// With 10k iterations, we expect: tip-a ~2000, tip-b ~6000, tip-c ~2000
|
||||
expect(counts['tip-b']!).toBeGreaterThan(counts['tip-a']! * 2);
|
||||
expect(counts['tip-b']!).toBeGreaterThan(counts['tip-c']! * 2);
|
||||
});
|
||||
|
||||
it('handles single tip', () => {
|
||||
expect(selectWeightedTip([{ text: 'only', weight: 1 }])).toBe('only');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,9 @@ import { Box, Text } from 'ink';
|
|||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const startupTips = [
|
||||
type Tip = string | { text: string; weight: number };
|
||||
|
||||
const startupTips: Tip[] = [
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
|
|
@ -20,13 +22,34 @@ const startupTips = [
|
|||
process.platform === 'win32'
|
||||
? 'You can switch permission mode quickly with Tab or /approval-mode.'
|
||||
: 'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
] as const;
|
||||
{
|
||||
text: 'Try /insight to generate personalized insights from your chat history.',
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
function tipText(tip: Tip): string {
|
||||
return typeof tip === 'string' ? tip : tip.text;
|
||||
}
|
||||
|
||||
function tipWeight(tip: Tip): number {
|
||||
return typeof tip === 'string' ? 1 : tip.weight;
|
||||
}
|
||||
|
||||
export function selectWeightedTip(tips: Tip[]): string {
|
||||
const totalWeight = tips.reduce((sum, tip) => sum + tipWeight(tip), 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
for (const tip of tips) {
|
||||
random -= tipWeight(tip);
|
||||
if (random <= 0) {
|
||||
return tipText(tip);
|
||||
}
|
||||
}
|
||||
return tipText(tips[tips.length - 1]!);
|
||||
}
|
||||
|
||||
export const Tips: React.FC = () => {
|
||||
const selectedTip = useMemo(() => {
|
||||
const randomIndex = Math.floor(Math.random() * startupTips.length);
|
||||
return startupTips[randomIndex];
|
||||
}, []);
|
||||
const selectedTip = useMemo(() => selectWeightedTip(startupTips), []);
|
||||
|
||||
return (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { InsightProgressProps } from '../../types.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
|
||||
interface InsightProgressMessageProps {
|
||||
progress: InsightProgressProps;
|
||||
}
|
||||
|
||||
export const InsightProgressMessage: React.FC<InsightProgressMessageProps> = ({
|
||||
progress,
|
||||
}) => {
|
||||
const { stage, progress: percent, isComplete, error } = progress;
|
||||
const width = 30;
|
||||
const completedWidth = Math.round((percent / 100) * width);
|
||||
const remainingWidth = width - completedWidth;
|
||||
|
||||
const bar =
|
||||
'█'.repeat(Math.max(0, completedWidth)) +
|
||||
'░'.repeat(Math.max(0, remainingWidth));
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.error}>✕ {stage}</Text>
|
||||
<Text color={theme.text.secondary}>{error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.status.success}>✓ {stage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.accent}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={theme.text.secondary}>{bar} </Text>
|
||||
<Text color={theme.text.accent}>
|
||||
{stage}
|
||||
{progress.detail ? ` (${progress.detail})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -330,7 +330,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<Text color={theme.text.link}>
|
||||
<RenderInline text={infoProps.prompt} />
|
||||
<RenderInline text={infoProps.prompt} textColor={theme.text.link} />
|
||||
</Text>
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue