Compare commits

..

17 Commits

Author SHA1 Message Date
yiliang114
b0e561ca73 chore(vscode-ide-companion/open-files-manager): update copyright headers to Qwen Team 2026-01-11 00:25:31 +08:00
yiliang114
563d68ad5b feat(vscode-ide-companion/services): add IPYNB code selection support and refactor OpenFilesManager 2026-01-10 23:51:51 +08:00
tanzhenxin
bde31d1261 Merge pull request #1448 from QwenLM/fix/openai-compatible
fix(core): handle missing delta in OpenAI stream chunks
2026-01-09 21:38:31 +08:00
tanzhenxin
cba9c424eb fix(core): handle missing delta in OpenAI stream chunks
Some OpenAI-compatible providers occasionally emit chat.completion.chunk choices
without a delta object. Guard optional reasoning_content access and add a
regression test to ensure chunk conversion does not throw.
2026-01-09 17:41:07 +08:00
tanzhenxin
6714f9ce3c Merge pull request #1351 from xuewenjie123/fix/editor-launch-issues
fix: resolve external editor launch failure on macOS and Windows
2026-01-09 11:07:43 +08:00
tanzhenxin
155d1f9518 Merge pull request #1428 from liqiongyu/fix/727-memory-show-respects-context-file
fix(cli): /memory show respects context.fileName
2026-01-09 10:29:27 +08:00
Mingholy
f776075aa8 Merge pull request #1439 from QwenLM/mingholy/fix/multi-provider-cold-start
fix: multi provider cold start issue
2026-01-08 18:54:42 +08:00
liqoingyu
0a0ab64da0 test(cli): make memoryCommand path assertions cross-platform 2026-01-07 20:28:28 +08:00
liqoingyu
8a15017593 fix(cli): /memory show respects context.fileName 2026-01-07 20:07:41 +08:00
xwj02155382
3d059b71de refactor: improve IDE context format and editor command error handling
- Change IDE context from JSON to plain text format for better LLM comprehension
  - Remove JSON.stringify() and code fences from getIdeContextParts()
  - Use human-readable format: 'Active file:', 'Cursor: line X, character Y'
  - Apply same format to delta updates: 'Files opened:', 'Files closed:', etc.
  - Update all related tests to match new plain text format

- Fix editor command fallback logic in useLaunchEditor
  - Throw clear error when no editor command is available
  - Remove meaningless fallback to last command in list
  - Provide helpful error message with tried commands and solution
2026-01-07 16:34:12 +08:00
xwj02155382
87dc618a21 revert: restore original editor command fallback logic for zed support
- Revert getExecutableCommand to use original fallback logic
- Revert getDiffCommand to use slice(0, -1) pattern
- Maintain proper support for zed editor with multiple command options ['zed', 'zeditor']
- Keep the caching optimization for commandExists
2026-01-06 11:09:29 +08:00
xwj02155382
94a5d828bd refactor: optimize commandExists with caching and simplify editor command logic
- Add caching layer for commandExists in useLaunchEditor.ts to avoid repeated execSync calls
- Import commandExists from core and wrap it with cache in CLI layer
- Simplify getExecutableCommand and getDiffCommand logic to remove redundant fallback
- For editors with single command, directly use first command instead of meaningless self-fallback
- Maintain support for editors with multiple commands (e.g., zed with 'zed' and 'zeditor')
2026-01-06 11:05:03 +08:00
xwj02155382
fd41309ed2 refactor: share editorCommands between core and cli packages
- Export editorCommands from @qwen-code/qwen-code-core
- Remove duplicate editorCommands definition in useLaunchEditor
- Import shared editorCommands configuration in CLI package
- Reduces code duplication and ensures consistency

This change makes the editor configuration a single source of truth,
making it easier to maintain and add new editors in the future.
2025-12-26 16:03:05 +08:00
xwj02155382
48bc0f35d7 perf: add cache for commandExists to fix CI timeout
- Add commandExistsCache Map to avoid repeated execSync calls
- Cache command existence check results to improve test performance
- Fix CI test timeout issue (was timing out after 7m)

The commandExists() function was being called frequently during tests,
causing slow test execution due to repeated system command calls.
By caching the results, we significantly improve performance in test
environments while maintaining the same functionality.
2025-12-26 13:52:37 +08:00
xwj02155382
e30c2dbe23 Merge branch 'fix/editor-launch-issues' of https://github.com/xuewenjie123/qwen-code into fix/editor-launch-issues 2025-12-26 11:22:22 +08:00
xwj02155382
e9204ecba9 fix: resolve editor launch issue on macOS for subagent editing
- Fixed ENOENT error when launching external editors (VS Code, etc.)
- Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.)
- Implemented command existence check to find available editor executable
- Supports both macOS and Windows platform-specific commands

Fixes #1180
2025-12-26 11:11:24 +08:00
xwj02155382
f24bda3d7b fix: resolve editor launch issue on macOS for subagent editing
- Fixed ENOENT error when launching external editors (VS Code, etc.)
- Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.)
- Implemented command existence check to find available editor executable
- Supports both macOS and Windows platform-specific commands

Fixes #1180
2025-12-26 10:17:52 +08:00
12 changed files with 563 additions and 104 deletions

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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 });
}

View File

@@ -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[] }
> = {

View File

@@ -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(() => {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}