mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-11 19:39:18 +00:00
Compare commits
33 Commits
mingholy/f
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e561ca73 | ||
|
|
563d68ad5b | ||
|
|
bde31d1261 | ||
|
|
cba9c424eb | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
36c142951a | ||
|
|
2b511d0b83 | ||
|
|
85bc0833b4 | ||
|
|
2662639280 | ||
|
|
b7ac94ecf6 | ||
|
|
be8259b218 | ||
|
|
ca4c36f233 | ||
|
|
f41308f34c | ||
|
|
0a33510304 | ||
|
|
82cbdee3b4 | ||
|
|
f6a753cf78 | ||
|
|
509d304742 | ||
|
|
6319a6ed56 | ||
|
|
ab07c2d89c | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
4d54a231b3 | ||
|
|
0f1cb162c9 | ||
|
|
3d059b71de | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17316,7 +17316,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17953,7 +17953,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -21413,7 +21413,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21425,7 +21425,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -311,7 +311,7 @@ class GeminiAgent {
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = config.getAuthType();
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
@@ -256,12 +256,16 @@ export async function main() {
|
||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||
try {
|
||||
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
// Fresh users may not have selected/persisted an authType yet.
|
||||
// In that case, defer auth prompting/selection to the main interactive flow.
|
||||
if (authType) {
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
await partialConfig.refreshAuth(authType);
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error authenticating:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -370,29 +370,30 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
// Check for initialization error first
|
||||
const currentAuthType = config.modelsConfig.getCurrentAuthType();
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
config.modelsConfig.getCurrentAuthType() &&
|
||||
settings.merged.security?.auth.enforcedType !==
|
||||
config.modelsConfig.getCurrentAuthType()
|
||||
currentAuthType &&
|
||||
settings.merged.security?.auth.enforcedType !== currentAuthType
|
||||
) {
|
||||
onAuthError(
|
||||
t(
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||
{
|
||||
enforcedType: settings.merged.security?.auth.enforcedType,
|
||||
currentType: config.modelsConfig.getCurrentAuthType(),
|
||||
enforcedType: String(settings.merged.security?.auth.enforcedType),
|
||||
currentType: String(currentAuthType),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (!settings.merged.security?.auth?.useExternal) {
|
||||
const error = validateAuthMethod(
|
||||
config.modelsConfig.getCurrentAuthType(),
|
||||
config,
|
||||
);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
// If no authType is selected yet, allow the auth UI flow to prompt the user.
|
||||
// Only validate credentials once a concrete authType exists.
|
||||
if (currentAuthType) {
|
||||
const error = validateAuthMethod(currentAuthType, config);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -11,9 +11,14 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
setGeminiMdFilename,
|
||||
type FileDiscoveryService,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -31,7 +36,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const readFile = vi.fn();
|
||||
return {
|
||||
readFile,
|
||||
default: {
|
||||
readFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||
const mockReadFile = readFile as unknown as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -52,6 +68,10 @@ describe('memoryCommand', () => {
|
||||
let mockGetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setGeminiMdFilename('QWEN.md');
|
||||
mockReadFile.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
showCommand = getSubCommand('show');
|
||||
|
||||
mockGetUserMemory = vi.fn();
|
||||
@@ -102,6 +122,52 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show project memory from the configured context file', async () => {
|
||||
const projectCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--project',
|
||||
);
|
||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||
mockReadFile.mockResolvedValue('project memory');
|
||||
|
||||
await projectCommand.action(mockContext, '');
|
||||
|
||||
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(expectedProjectPath),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show global memory from the configured context file', async () => {
|
||||
const globalCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--global',
|
||||
);
|
||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||
mockReadFile.mockResolvedValue('global memory');
|
||||
|
||||
await globalCommand.action(mockContext, '');
|
||||
|
||||
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Global memory content'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
@@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const projectMemoryPath = path.join(
|
||||
workingDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const memoryContent = await fs.readFile(
|
||||
projectMemoryPath,
|
||||
'utf-8',
|
||||
@@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = {
|
||||
const globalMemoryPath = path.join(
|
||||
os.homedir(),
|
||||
QWEN_DIR,
|
||||
'QWEN.md',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const globalMemoryContent = await fs.readFile(
|
||||
globalMemoryPath,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// Local error state for displaying errors within the dialog
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
const authType = config?.getAuthType();
|
||||
const effectiveConfig =
|
||||
(config?.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
@@ -208,7 +208,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
);
|
||||
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredKey = `${authType}::${preferredModelId}`;
|
||||
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -219,10 +219,12 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey),
|
||||
[MODEL_OPTIONS, preferredKey],
|
||||
);
|
||||
const initialIndex = useMemo(() => {
|
||||
const index = MODEL_OPTIONS.findIndex(
|
||||
(option) => option.value === preferredKey,
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
}, [MODEL_OPTIONS, preferredKey]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (selected: string) => {
|
||||
@@ -339,7 +341,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
authType: authType ? String(authType) : t('(none)'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
editorCommands,
|
||||
commandExists as coreCommandExists,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
/**
|
||||
* Cache for command existence checks to avoid repeated execSync calls.
|
||||
*/
|
||||
const commandExistsCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Check if a command exists in the system with caching.
|
||||
* Results are cached to improve performance in test environments.
|
||||
*/
|
||||
function commandExists(cmd: string): boolean {
|
||||
if (commandExistsCache.has(cmd)) {
|
||||
return commandExistsCache.get(cmd)!;
|
||||
}
|
||||
|
||||
const exists = coreCommandExists(cmd);
|
||||
commandExistsCache.set(cmd, exists);
|
||||
return exists;
|
||||
}
|
||||
/**
|
||||
* Get the actual executable command for an editor type.
|
||||
*/
|
||||
function getExecutableCommand(editorType: EditorType): string {
|
||||
const commandConfig = editorCommands[editorType];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
|
||||
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
||||
|
||||
if (!availableCommand) {
|
||||
throw new Error(
|
||||
`No available editor command found for ${editorType}. ` +
|
||||
`Tried: ${commands.join(', ')}. ` +
|
||||
`Please install one of these editors or set a different preferredEditor in settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return availableCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the editor command to use based on user preferences and platform.
|
||||
*/
|
||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||
if (preferredEditor) {
|
||||
return preferredEditor;
|
||||
return getExecutableCommand(preferredEditor);
|
||||
}
|
||||
|
||||
// Platform-specific defaults with UI preference for macOS
|
||||
@@ -63,8 +100,14 @@ export function useLaunchEditor() {
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
|
||||
// On Windows, .cmd and .bat files need shell: true
|
||||
const needsShell =
|
||||
process.platform === 'win32' &&
|
||||
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
||||
|
||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||
stdio: 'inherit',
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function resolveCliGenerationConfig(
|
||||
const { argv, settings, selectedAuthType } = inputs;
|
||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||
|
||||
const authType = selectedAuthType ?? AuthType.QWEN_OAUTH;
|
||||
const authType = selectedAuthType;
|
||||
|
||||
const configSources: ModelConfigSourcesInput = {
|
||||
authType,
|
||||
|
||||
@@ -20,21 +20,27 @@ export async function validateNonInteractiveAuth(
|
||||
try {
|
||||
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
|
||||
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
|
||||
if (!authType) {
|
||||
throw new Error(
|
||||
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
const resolvedAuthType: NonNullable<typeof authType> = authType;
|
||||
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${authType}. Please re-authenticate with the correct type.`;
|
||||
if (enforcedType && enforcedType !== resolvedAuthType) {
|
||||
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!useExternalAuth) {
|
||||
const err = validateAuthMethod(authType, nonInteractiveConfig);
|
||||
const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig);
|
||||
if (err != null) {
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
|
||||
await nonInteractiveConfig.refreshAuth(authType);
|
||||
await nonInteractiveConfig.refreshAuth(resolvedAuthType);
|
||||
return nonInteractiveConfig;
|
||||
} catch (error) {
|
||||
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1276,7 +1276,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getAuthType(): AuthType | undefined {
|
||||
return this.contentGeneratorConfig.authType;
|
||||
return this.contentGeneratorConfig?.authType;
|
||||
}
|
||||
|
||||
getCliVersion(): string | undefined {
|
||||
|
||||
@@ -1058,26 +1058,18 @@ describe('Gemini Client (client.ts)', () => {
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
activeFile: {
|
||||
path: '/path/to/active/file.ts',
|
||||
cursor: {
|
||||
line: 5,
|
||||
character: 10,
|
||||
},
|
||||
selectedText: 'hello',
|
||||
},
|
||||
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Active file:
|
||||
Path: /path/to/active/file.ts
|
||||
Cursor: line 5, character 10
|
||||
Selected text:
|
||||
\`\`\`
|
||||
`.trim();
|
||||
hello
|
||||
\`\`\`
|
||||
|
||||
Other open files:
|
||||
- /path/to/recent/file1.ts
|
||||
- /path/to/recent/file2.ts`;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1177,25 +1169,14 @@ ${JSON.stringify(
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
activeFile: {
|
||||
path: '/path/to/active/file.ts',
|
||||
cursor: {
|
||||
line: 5,
|
||||
character: 10,
|
||||
},
|
||||
selectedText: 'hello',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Active file:
|
||||
Path: /path/to/active/file.ts
|
||||
Cursor: line 5, character 10
|
||||
Selected text:
|
||||
\`\`\`
|
||||
`.trim();
|
||||
hello
|
||||
\`\`\``;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1254,18 +1235,10 @@ ${JSON.stringify(
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
`.trim();
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Other open files:
|
||||
- /path/to/recent/file1.ts
|
||||
- /path/to/recent/file2.ts`;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1782,11 +1755,9 @@ ${JSON.stringify(
|
||||
// Also verify it's the full context, not a delta.
|
||||
const call = mockChat.addHistory.mock.calls[0][0];
|
||||
const contextText = call.parts[0].text;
|
||||
const contextJson = JSON.parse(
|
||||
contextText.match(/```json\n(.*)\n```/s)![1],
|
||||
);
|
||||
expect(contextJson).toHaveProperty('activeFile');
|
||||
expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts');
|
||||
// Verify it contains the active file information in plain text format
|
||||
expect(contextText).toContain('Active file:');
|
||||
expect(contextText).toContain('Path: /path/to/active/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1989,7 +1960,7 @@ ${JSON.stringify(
|
||||
);
|
||||
expect(contextCall).toBeDefined();
|
||||
expect(JSON.stringify(contextCall![0])).toContain(
|
||||
"Here is the user's editor context as a JSON object",
|
||||
"Here is the user's editor context.",
|
||||
);
|
||||
// Check that the sent context is the new one (fileB.ts)
|
||||
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
|
||||
@@ -2025,9 +1996,7 @@ ${JSON.stringify(
|
||||
|
||||
// Assert: Full context for fileA.ts was sent and stored.
|
||||
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||
expect(JSON.stringify(initialCall)).toContain(
|
||||
"user's editor context as a JSON object",
|
||||
);
|
||||
expect(JSON.stringify(initialCall)).toContain("user's editor context.");
|
||||
expect(JSON.stringify(initialCall)).toContain('fileA.ts');
|
||||
// This implicitly tests that `lastSentIdeContext` is now set internally by the client.
|
||||
vi.mocked(mockChat.addHistory!).mockClear();
|
||||
@@ -2125,9 +2094,9 @@ ${JSON.stringify(
|
||||
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||
expect(JSON.stringify(finalCall)).toContain('summary of changes');
|
||||
// The delta should reflect fileA being closed and fileC being opened.
|
||||
expect(JSON.stringify(finalCall)).toContain('filesClosed');
|
||||
expect(JSON.stringify(finalCall)).toContain('Files closed');
|
||||
expect(JSON.stringify(finalCall)).toContain('fileA.ts');
|
||||
expect(JSON.stringify(finalCall)).toContain('activeFileChanged');
|
||||
expect(JSON.stringify(finalCall)).toContain('Active file changed');
|
||||
expect(JSON.stringify(finalCall)).toContain('fileC.ts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,42 +218,48 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
if (forceFullContext || !this.lastSentIdeContext) {
|
||||
// Send full context as JSON
|
||||
// Send full context as plain text
|
||||
const openFiles = currentIdeContext.workspaceState?.openFiles || [];
|
||||
const activeFile = openFiles.find((f) => f.isActive);
|
||||
const otherOpenFiles = openFiles
|
||||
.filter((f) => !f.isActive)
|
||||
.map((f) => f.path);
|
||||
|
||||
const contextData: Record<string, unknown> = {};
|
||||
const contextLines: string[] = [];
|
||||
|
||||
if (activeFile) {
|
||||
contextData['activeFile'] = {
|
||||
path: activeFile.path,
|
||||
cursor: activeFile.cursor
|
||||
? {
|
||||
line: activeFile.cursor.line,
|
||||
character: activeFile.cursor.character,
|
||||
}
|
||||
: undefined,
|
||||
selectedText: activeFile.selectedText || undefined,
|
||||
};
|
||||
contextLines.push('Active file:');
|
||||
contextLines.push(` Path: ${activeFile.path}`);
|
||||
if (activeFile.cursor) {
|
||||
contextLines.push(
|
||||
` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`,
|
||||
);
|
||||
}
|
||||
if (activeFile.selectedText) {
|
||||
contextLines.push(' Selected text:');
|
||||
contextLines.push('```');
|
||||
contextLines.push(activeFile.selectedText);
|
||||
contextLines.push('```');
|
||||
}
|
||||
}
|
||||
|
||||
if (otherOpenFiles.length > 0) {
|
||||
contextData['otherOpenFiles'] = otherOpenFiles;
|
||||
if (contextLines.length > 0) {
|
||||
contextLines.push('');
|
||||
}
|
||||
contextLines.push('Other open files:');
|
||||
for (const filePath of otherOpenFiles) {
|
||||
contextLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(contextData).length === 0) {
|
||||
if (contextLines.length === 0) {
|
||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(contextData, null, 2);
|
||||
const contextParts = [
|
||||
"Here is the user's editor context as a JSON object. This is for your information only.",
|
||||
'```json',
|
||||
jsonString,
|
||||
'```',
|
||||
"Here is the user's editor context. This is for your information only.",
|
||||
contextLines.join('\n'),
|
||||
];
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
@@ -264,9 +270,8 @@ export class GeminiClient {
|
||||
newIdeContext: currentIdeContext,
|
||||
};
|
||||
} else {
|
||||
// Calculate and send delta as JSON
|
||||
const delta: Record<string, unknown> = {};
|
||||
const changes: Record<string, unknown> = {};
|
||||
// Calculate and send delta as plain text
|
||||
const changeLines: string[] = [];
|
||||
|
||||
const lastFiles = new Map(
|
||||
(this.lastSentIdeContext.workspaceState?.openFiles || []).map(
|
||||
@@ -287,7 +292,10 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
if (openedFiles.length > 0) {
|
||||
changes['filesOpened'] = openedFiles;
|
||||
changeLines.push('Files opened:');
|
||||
for (const filePath of openedFiles) {
|
||||
changeLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const closedFiles: string[] = [];
|
||||
@@ -297,7 +305,13 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
if (closedFiles.length > 0) {
|
||||
changes['filesClosed'] = closedFiles;
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Files closed:');
|
||||
for (const filePath of closedFiles) {
|
||||
changeLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActiveFile = (
|
||||
@@ -309,16 +323,22 @@ export class GeminiClient {
|
||||
|
||||
if (currentActiveFile) {
|
||||
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
|
||||
changes['activeFileChanged'] = {
|
||||
path: currentActiveFile.path,
|
||||
cursor: currentActiveFile.cursor
|
||||
? {
|
||||
line: currentActiveFile.cursor.line,
|
||||
character: currentActiveFile.cursor.character,
|
||||
}
|
||||
: undefined,
|
||||
selectedText: currentActiveFile.selectedText || undefined,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Active file changed:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
if (currentActiveFile.cursor) {
|
||||
changeLines.push(
|
||||
` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`,
|
||||
);
|
||||
}
|
||||
if (currentActiveFile.selectedText) {
|
||||
changeLines.push(' Selected text:');
|
||||
changeLines.push('```');
|
||||
changeLines.push(currentActiveFile.selectedText);
|
||||
changeLines.push('```');
|
||||
}
|
||||
} else {
|
||||
const lastCursor = lastActiveFile.cursor;
|
||||
const currentCursor = currentActiveFile.cursor;
|
||||
@@ -328,42 +348,50 @@ export class GeminiClient {
|
||||
lastCursor.line !== currentCursor.line ||
|
||||
lastCursor.character !== currentCursor.character)
|
||||
) {
|
||||
changes['cursorMoved'] = {
|
||||
path: currentActiveFile.path,
|
||||
cursor: {
|
||||
line: currentCursor.line,
|
||||
character: currentCursor.character,
|
||||
},
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Cursor moved:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
changeLines.push(
|
||||
` New position: line ${currentCursor.line}, character ${currentCursor.character}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lastSelectedText = lastActiveFile.selectedText || '';
|
||||
const currentSelectedText = currentActiveFile.selectedText || '';
|
||||
if (lastSelectedText !== currentSelectedText) {
|
||||
changes['selectionChanged'] = {
|
||||
path: currentActiveFile.path,
|
||||
selectedText: currentSelectedText,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Selection changed:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
if (currentSelectedText) {
|
||||
changeLines.push(' Selected text:');
|
||||
changeLines.push('```');
|
||||
changeLines.push(currentSelectedText);
|
||||
changeLines.push('```');
|
||||
} else {
|
||||
changeLines.push(' Selected text: (none)');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (lastActiveFile) {
|
||||
changes['activeFileChanged'] = {
|
||||
path: null,
|
||||
previousPath: lastActiveFile.path,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Active file changed:');
|
||||
changeLines.push(' No active file');
|
||||
changeLines.push(` Previous path: ${lastActiveFile.path}`);
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
if (changeLines.length === 0) {
|
||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||
}
|
||||
|
||||
delta['changes'] = changes;
|
||||
const jsonString = JSON.stringify(delta, null, 2);
|
||||
const contextParts = [
|
||||
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
|
||||
'```json',
|
||||
jsonString,
|
||||
'```',
|
||||
"Here is a summary of changes in the user's editor context. This is for your information only.",
|
||||
changeLines.join('\n'),
|
||||
];
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
|
||||
@@ -207,6 +207,27 @@ describe('OpenAIContentConverter', () => {
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when streaming chunk has no delta', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-2',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
// Some OpenAI-compatible providers may omit delta entirely.
|
||||
delta: undefined,
|
||||
finish_reason: null,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToOpenAI', () => {
|
||||
|
||||
@@ -799,7 +799,7 @@ export class OpenAIContentConverter {
|
||||
const parts: Part[] = [];
|
||||
|
||||
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
|
||||
.reasoning_content;
|
||||
?.reasoning_content;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface ModelConfigSettingsInput {
|
||||
*/
|
||||
export interface ModelConfigSourcesInput {
|
||||
/** Authentication type */
|
||||
authType: AuthType;
|
||||
authType?: AuthType;
|
||||
|
||||
/** CLI arguments (highest priority for user-provided values) */
|
||||
cli?: ModelConfigCliInput;
|
||||
@@ -128,9 +128,11 @@ export function resolveModelConfig(
|
||||
return resolveQwenOAuthConfig(input, warnings);
|
||||
}
|
||||
|
||||
// Get auth-specific env var mappings
|
||||
const envMapping =
|
||||
AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI];
|
||||
// Get auth-specific env var mappings.
|
||||
// If authType is not provided, do not read any auth env vars.
|
||||
const envMapping = authType
|
||||
? AUTH_ENV_MAPPINGS[authType]
|
||||
: { model: [], apiKey: [], baseUrl: [] };
|
||||
|
||||
// Build layers for each field in priority order
|
||||
// Priority: modelProvider > cli > env > settings > default
|
||||
@@ -138,7 +140,7 @@ export function resolveModelConfig(
|
||||
// ---- Model ----
|
||||
const modelLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider) {
|
||||
if (authType && modelProvider) {
|
||||
modelLayers.push(
|
||||
layer(
|
||||
modelProvider.id,
|
||||
@@ -156,7 +158,7 @@ export function resolveModelConfig(
|
||||
modelLayers.push(layer(settings.model, settingsSource('model.name')));
|
||||
}
|
||||
|
||||
const defaultModel = DEFAULT_MODELS[authType] || '';
|
||||
const defaultModel = authType ? DEFAULT_MODELS[authType] : '';
|
||||
const modelResult = resolveField(
|
||||
modelLayers,
|
||||
defaultModel,
|
||||
@@ -168,7 +170,7 @@ export function resolveModelConfig(
|
||||
const apiKeyLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
// For modelProvider, read from the specified envKey
|
||||
if (modelProvider?.envKey) {
|
||||
if (authType && modelProvider?.envKey) {
|
||||
const apiKeyFromEnv = env[modelProvider.envKey];
|
||||
if (apiKeyFromEnv) {
|
||||
apiKeyLayers.push(
|
||||
@@ -200,7 +202,7 @@ export function resolveModelConfig(
|
||||
// ---- Base URL ----
|
||||
const baseUrlLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider?.baseUrl) {
|
||||
if (authType && modelProvider?.baseUrl) {
|
||||
baseUrlLayers.push(
|
||||
layer(
|
||||
modelProvider.baseUrl,
|
||||
@@ -227,7 +229,7 @@ export function resolveModelConfig(
|
||||
|
||||
// ---- API Key Env Key (for error messages) ----
|
||||
let apiKeyEnvKey: string | undefined;
|
||||
if (modelProvider?.envKey) {
|
||||
if (authType && modelProvider?.envKey) {
|
||||
apiKeyEnvKey = modelProvider.envKey;
|
||||
sources['apiKeyEnvKey'] = modelProvidersSource(
|
||||
authType,
|
||||
@@ -248,7 +250,7 @@ export function resolveModelConfig(
|
||||
// Build final config
|
||||
const config: ContentGeneratorConfig = {
|
||||
authType,
|
||||
model: modelResult.value,
|
||||
model: modelResult.value || '',
|
||||
apiKey: apiKeyResult?.value,
|
||||
apiKeyEnvKey,
|
||||
baseUrl: baseUrlResult?.value,
|
||||
@@ -335,7 +337,7 @@ function resolveQwenOAuthConfig(
|
||||
function resolveGenerationConfig(
|
||||
settingsConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
modelProviderConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
authType: AuthType,
|
||||
authType: AuthType | undefined,
|
||||
modelId: string | undefined,
|
||||
sources: ConfigSources,
|
||||
): Partial<ContentGeneratorConfig> {
|
||||
@@ -343,7 +345,7 @@ function resolveGenerationConfig(
|
||||
|
||||
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
|
||||
// ModelProvider config takes priority
|
||||
if (modelProviderConfig && field in modelProviderConfig) {
|
||||
if (authType && modelProviderConfig && field in modelProviderConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result as any)[field] = modelProviderConfig[field];
|
||||
sources[field] = modelProvidersSource(
|
||||
|
||||
@@ -464,6 +464,22 @@ describe('ModelsConfig', () => {
|
||||
expect(gc.apiKeyEnvKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should apply Qwen OAuth apiKey placeholder during syncAfterAuthRefresh for fresh users', () => {
|
||||
// Fresh user: authType not selected yet (currentAuthType undefined).
|
||||
const modelsConfig = new ModelsConfig();
|
||||
|
||||
// Config.refreshAuth passes modelId from modelsConfig.getModel(), which falls back to DEFAULT_QWEN_MODEL.
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.QWEN_OAUTH,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('coder-model');
|
||||
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
||||
expect(gc.apiKeyEnvKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
|
||||
@@ -70,7 +70,7 @@ export class ModelsConfig {
|
||||
private readonly modelRegistry: ModelRegistry;
|
||||
|
||||
// Current selection state
|
||||
private currentAuthType: AuthType;
|
||||
private currentAuthType: AuthType | undefined;
|
||||
|
||||
// Generation config state
|
||||
private _generationConfig: Partial<ContentGeneratorConfig>;
|
||||
@@ -115,7 +115,7 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
private snapshotState(): {
|
||||
currentAuthType: AuthType;
|
||||
currentAuthType: AuthType | undefined;
|
||||
generationConfig: Partial<ContentGeneratorConfig>;
|
||||
generationConfigSources: ContentGeneratorConfigSources;
|
||||
strictModelProviderSelection: boolean;
|
||||
@@ -162,7 +162,7 @@ export class ModelsConfig {
|
||||
this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined;
|
||||
|
||||
// Initialize selection state
|
||||
this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH;
|
||||
this.currentAuthType = options.initialAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,13 +175,13 @@ export class ModelsConfig {
|
||||
/**
|
||||
* Get current authType
|
||||
*/
|
||||
getCurrentAuthType(): AuthType {
|
||||
getCurrentAuthType(): AuthType | undefined {
|
||||
return this.currentAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authType was explicitly provided (via CLI or settings).
|
||||
* If false, the default QWEN_OAUTH is being used.
|
||||
* If false, no authType was provided yet (fresh user).
|
||||
*/
|
||||
wasAuthTypeExplicitlyProvided(): boolean {
|
||||
return this.authTypeWasExplicitlyProvided;
|
||||
@@ -191,7 +191,9 @@ export class ModelsConfig {
|
||||
* Get available models for current authType
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this.modelRegistry.getModelsForAuthType(this.currentAuthType);
|
||||
return this.currentAuthType
|
||||
? this.modelRegistry.getModelsForAuthType(this.currentAuthType)
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +233,10 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
// If model exists in registry, use full switch logic
|
||||
if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) {
|
||||
if (
|
||||
this.currentAuthType &&
|
||||
this.modelRegistry.hasModel(this.currentAuthType, newModel)
|
||||
) {
|
||||
await this.switchModel(this.currentAuthType, newModel);
|
||||
return;
|
||||
}
|
||||
@@ -538,19 +543,26 @@ export class ModelsConfig {
|
||||
* - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes
|
||||
*/
|
||||
private checkRequiresRefresh(previousModelId: string): boolean {
|
||||
// Defensive: this method is only called after switchModel() sets currentAuthType,
|
||||
// but keep type safety for any future callsites.
|
||||
const authType = this.currentAuthType;
|
||||
if (!authType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For Qwen OAuth, model switches within the same authType can always be hot-updated
|
||||
// (coder-model <-> vision-model don't require ContentGenerator recreation)
|
||||
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get previous and current model configs
|
||||
const previousModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
authType,
|
||||
previousModelId,
|
||||
);
|
||||
const currentModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
authType,
|
||||
this._generationConfig.model || '',
|
||||
);
|
||||
|
||||
@@ -602,8 +614,11 @@ export class ModelsConfig {
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
|
||||
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
|
||||
// before applying defaults.
|
||||
this.currentAuthType = authType;
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
}
|
||||
} else {
|
||||
this.currentAuthType = authType;
|
||||
|
||||
@@ -601,8 +601,17 @@ async function authWithQwenDeviceFlow(
|
||||
console.log('Waiting for authorization to complete...\n');
|
||||
};
|
||||
|
||||
// If browser launch is not suppressed, try to open the URL
|
||||
if (!config.isBrowserLaunchSuppressed()) {
|
||||
// Always show the fallback message in non-interactive environments to ensure
|
||||
// users can see the authorization URL even if browser launching is attempted.
|
||||
// This is critical for headless/remote environments where browser launching
|
||||
// may silently fail without throwing an error.
|
||||
if (config.isBrowserLaunchSuppressed()) {
|
||||
// Browser launch is suppressed, show fallback message
|
||||
showFallbackMessage();
|
||||
} else {
|
||||
// Try to open the URL in browser, but always show the URL as fallback
|
||||
// to handle cases where browser launch silently fails (e.g., headless servers)
|
||||
showFallbackMessage();
|
||||
try {
|
||||
const childProcess = await open(deviceAuth.verification_uri_complete);
|
||||
|
||||
@@ -611,19 +620,19 @@ async function authWithQwenDeviceFlow(
|
||||
// in a minimal Docker container), it will emit an unhandled 'error' event,
|
||||
// causing the entire Node.js process to crash.
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => {
|
||||
childProcess.on('error', (err) => {
|
||||
console.debug(
|
||||
'Failed to open browser. Visit this URL to authorize:',
|
||||
'Browser launch failed:',
|
||||
err.message || 'Unknown error',
|
||||
);
|
||||
showFallbackMessage();
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
} catch (err) {
|
||||
console.debug(
|
||||
'Failed to open browser:',
|
||||
err instanceof Error ? err.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Browser launch is suppressed, show fallback message
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
// Emit auth progress event
|
||||
|
||||
@@ -36,7 +36,7 @@ interface DiffCommand {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
export function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
|
||||
@@ -52,7 +52,7 @@ function commandExists(cmd: string): boolean {
|
||||
* Editor command configurations for different platforms.
|
||||
* Each editor can have multiple possible command names, listed in order of preference.
|
||||
*/
|
||||
const editorCommands: Record<
|
||||
export const editorCommands: Record<
|
||||
EditorType,
|
||||
{ win32: string[]; default: string[] }
|
||||
> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vscode from 'vscode';
|
||||
import { OpenFilesManager, MAX_FILES } from './open-files-manager.js';
|
||||
import { OpenFilesManager } from './open-files-manager.js';
|
||||
import { MAX_FILES } from './services/open-files-manager/constants.js';
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
EventEmitter: vi.fn(() => {
|
||||
|
||||
@@ -9,9 +9,23 @@ import type {
|
||||
File,
|
||||
IdeContext,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
import {
|
||||
isFileUri,
|
||||
isNotebookFileUri,
|
||||
isNotebookCellUri,
|
||||
removeFile,
|
||||
renameFile,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './services/open-files-manager/utils.js';
|
||||
import {
|
||||
addOrMoveToFront,
|
||||
updateActiveContext,
|
||||
} from './services/open-files-manager/text-handler.js';
|
||||
import {
|
||||
addOrMoveToFrontNotebook,
|
||||
updateNotebookActiveContext,
|
||||
updateNotebookCellSelection,
|
||||
} from './services/open-files-manager/notebook-handler.js';
|
||||
|
||||
/**
|
||||
* Keeps track of the workspace state, including open files, cursor position, and selected text.
|
||||
@@ -25,33 +39,102 @@ export class OpenFilesManager {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
if (editor && this.isFileUri(editor.document.uri)) {
|
||||
this.addOrMoveToFront(editor);
|
||||
if (editor && isFileUri(editor.document.uri)) {
|
||||
addOrMoveToFront(this.openFiles, editor);
|
||||
this.fireWithDebounce();
|
||||
} else if (editor && isNotebookCellUri(editor.document.uri)) {
|
||||
// Handle when a notebook cell becomes active (which indicates the notebook is active)
|
||||
const notebookUri = getNotebookUriFromCellUri(editor.document.uri);
|
||||
if (notebookUri && isNotebookFileUri(notebookUri)) {
|
||||
// Find the notebook editor for this cell
|
||||
const notebookEditor = vscode.window.visibleNotebookEditors.find(
|
||||
(nbEditor) =>
|
||||
nbEditor.notebook.uri.toString() === notebookUri.toString(),
|
||||
);
|
||||
if (notebookEditor) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for when notebook editors gain focus by monitoring focus changes
|
||||
// Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event,
|
||||
// we monitor when visible notebook editors change and assume the last one shown is active
|
||||
let notebookFocusWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeVisibleNotebookEditors) {
|
||||
notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors(
|
||||
() => {
|
||||
// When visible notebook editors change, the currently focused one is likely the active one
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
|
||||
(event) => {
|
||||
if (this.isFileUri(event.textEditor.document.uri)) {
|
||||
this.updateActiveContext(event.textEditor);
|
||||
if (isFileUri(event.textEditor.document.uri)) {
|
||||
updateActiveContext(this.openFiles, event.textEditor);
|
||||
this.fireWithDebounce();
|
||||
} else if (isNotebookCellUri(event.textEditor.document.uri)) {
|
||||
// Handle text selections within notebook cells
|
||||
updateNotebookCellSelection(
|
||||
this.openFiles,
|
||||
event.textEditor,
|
||||
event.selections,
|
||||
);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add notebook cell selection watcher for .ipynb files if the API is available
|
||||
let notebookCellSelectionWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeNotebookEditorSelection) {
|
||||
notebookCellSelectionWatcher =
|
||||
vscode.window.onDidChangeNotebookEditorSelection((event) => {
|
||||
if (isNotebookFileUri(event.notebookEditor.notebook.uri)) {
|
||||
// Ensure the notebook is added to the active list if selected
|
||||
addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor);
|
||||
updateNotebookActiveContext(this.openFiles, event.notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
if (this.isFileUri(document.uri)) {
|
||||
this.remove(document.uri);
|
||||
if (isFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
|
||||
// Add notebook close watcher if the API is available
|
||||
let notebookCloseWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.workspace.onDidCloseNotebookDocument) {
|
||||
notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument(
|
||||
(document) => {
|
||||
if (isNotebookFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
||||
for (const uri of event.files) {
|
||||
if (this.isFileUri(uri)) {
|
||||
this.remove(uri);
|
||||
if (isFileUri(uri) || isNotebookFileUri(uri)) {
|
||||
removeFile(this.openFiles, uri);
|
||||
}
|
||||
}
|
||||
this.fireWithDebounce();
|
||||
@@ -59,12 +142,12 @@ export class OpenFilesManager {
|
||||
|
||||
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
||||
for (const { oldUri, newUri } of event.files) {
|
||||
if (this.isFileUri(oldUri)) {
|
||||
if (this.isFileUri(newUri)) {
|
||||
this.rename(oldUri, newUri);
|
||||
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
|
||||
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
|
||||
renameFile(this.openFiles, oldUri, newUri);
|
||||
} else {
|
||||
// The file was renamed to a non-file URI, so we should remove it.
|
||||
this.remove(oldUri);
|
||||
removeFile(this.openFiles, oldUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,87 +162,37 @@ export class OpenFilesManager {
|
||||
renameWatcher,
|
||||
);
|
||||
|
||||
// Conditionally add notebook-specific watchers if they were created
|
||||
if (notebookCellSelectionWatcher) {
|
||||
context.subscriptions.push(notebookCellSelectionWatcher);
|
||||
}
|
||||
|
||||
if (notebookCloseWatcher) {
|
||||
context.subscriptions.push(notebookCloseWatcher);
|
||||
}
|
||||
|
||||
if (notebookFocusWatcher) {
|
||||
context.subscriptions.push(notebookFocusWatcher);
|
||||
}
|
||||
|
||||
// Just add current active file on start-up.
|
||||
if (
|
||||
vscode.window.activeTextEditor &&
|
||||
this.isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
) {
|
||||
this.addOrMoveToFront(vscode.window.activeTextEditor);
|
||||
}
|
||||
}
|
||||
|
||||
private isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
private addOrMoveToFront(editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
const currentActive = this.openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
|
||||
}
|
||||
|
||||
// Remove if it exists
|
||||
const index = this.openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
// Also add current active notebook if applicable and the API is available
|
||||
if (
|
||||
vscode.window.activeNotebookEditor &&
|
||||
isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(
|
||||
this.openFiles,
|
||||
vscode.window.activeNotebookEditor,
|
||||
);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
this.openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
if (this.openFiles.length > MAX_FILES) {
|
||||
this.openFiles.pop();
|
||||
}
|
||||
|
||||
this.updateActiveContext(editor);
|
||||
}
|
||||
|
||||
private remove(uri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
private updateActiveContext(editor: vscode.TextEditor) {
|
||||
const file = this.openFiles.find(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
|
||||
selectedText =
|
||||
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
|
||||
}
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
|
||||
private fireWithDebounce() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFrontNotebook(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: notebookEditor.notebook.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateNotebookActiveContext(openFiles, notebookEditor);
|
||||
}
|
||||
|
||||
export function updateNotebookActiveContext(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
const file = openFiles.find(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For notebook editors, selections may span multiple cells
|
||||
// We'll gather selected text from all selected cells
|
||||
const selections = notebookEditor.selections;
|
||||
let combinedSelectedText = '';
|
||||
|
||||
for (const selection of selections) {
|
||||
// Process each selected cell range
|
||||
for (let i = selection.start; i < selection.end; i++) {
|
||||
const cell = notebookEditor.notebook.cellAt(i);
|
||||
if (cell && cell.kind === vscode.NotebookCellKind.Code) {
|
||||
// For now, we'll get the full cell content if it's in a selection
|
||||
// TODO: Implement per-cell cursor position and finer-grained selection if needed
|
||||
combinedSelectedText += cell.document.getText() + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedSelectedText) {
|
||||
combinedSelectedText = combinedSelectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
combinedSelectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNotebookCellSelection(
|
||||
openFiles: File[],
|
||||
cellEditor: vscode.TextEditor,
|
||||
selections: readonly vscode.Selection[],
|
||||
) {
|
||||
// Find the parent notebook by traversing the URI
|
||||
const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri);
|
||||
if (!notebookUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the corresponding file entry for this notebook
|
||||
const file = openFiles.find((f) => f.path === notebookUri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the selected text from the cell editor
|
||||
let selectedText = '';
|
||||
for (const selection of selections) {
|
||||
const text = cellEditor.document.getText(selection);
|
||||
if (text) {
|
||||
selectedText += text + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedText) {
|
||||
selectedText = selectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
selectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateActiveContext(openFiles, editor);
|
||||
}
|
||||
|
||||
export function updateActiveContext(
|
||||
openFiles: File[],
|
||||
editor: vscode.TextEditor,
|
||||
) {
|
||||
const file = openFiles.find((f) => f.path === editor.document.uri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH);
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
|
||||
export function isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
export function isNotebookFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb');
|
||||
}
|
||||
|
||||
export function isNotebookCellUri(uri: vscode.Uri): boolean {
|
||||
// Notebook cell URIs have the scheme 'vscode-notebook-cell'
|
||||
return uri.scheme === 'vscode-notebook-cell';
|
||||
}
|
||||
|
||||
export function removeFile(openFiles: File[], uri: vscode.Uri): void {
|
||||
const index = openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function renameFile(
|
||||
openFiles: File[],
|
||||
oldUri: vscode.Uri,
|
||||
newUri: vscode.Uri,
|
||||
): void {
|
||||
const index = openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivateCurrentActiveFile(openFiles: File[]): void {
|
||||
const currentActive = openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceMaxFiles(openFiles: File[], maxFiles: number): void {
|
||||
if (openFiles.length > maxFiles) {
|
||||
openFiles.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateSelectedText(
|
||||
selectedText: string | undefined,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
if (!selectedText) {
|
||||
return undefined;
|
||||
}
|
||||
if (selectedText.length > maxLength) {
|
||||
return selectedText.substring(0, maxLength) + '... [TRUNCATED]';
|
||||
}
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
export function getNotebookUriFromCellUri(
|
||||
cellUri: vscode.Uri,
|
||||
): vscode.Uri | null {
|
||||
// Most efficient approach: Check if the currently active notebook editor contains this cell
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) {
|
||||
const cell = activeNotebookEditor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return activeNotebookEditor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not in the active editor, check all visible notebook editors
|
||||
for (const editor of vscode.window.visibleNotebookEditors) {
|
||||
if (
|
||||
editor !== activeNotebookEditor &&
|
||||
isNotebookFileUri(editor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < editor.notebook.cellCount; i++) {
|
||||
const cell = editor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return editor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user