Compare commits

..

6 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
39e409b5e5 Add troubleshooting documentation for fixed CLI interactivity issue
Co-authored-by: pomelo-nwu <10703060+pomelo-nwu@users.noreply.github.com>
2025-08-26 12:04:03 +00:00
copilot-swe-agent[bot]
f5ee3df219 Fix CLI interactivity issue by eliminating multiple useKeypress hook conflicts
Co-authored-by: pomelo-nwu <10703060+pomelo-nwu@users.noreply.github.com>
2025-08-26 12:00:12 +00:00
copilot-swe-agent[bot]
b03daebbc1 Initial plan 2025-08-26 11:42:44 +00:00
Fan
472df045d3 Fix parallel tool use (#400) 2025-08-26 17:01:09 +08:00
tanzhenxin
1baf5d795f Fix GitHub Workflows Configuration Issues (#451) 2025-08-26 16:54:52 +08:00
pomelo
98fd0f6a89 feat: update /docs link (#438) 2025-08-26 15:49:29 +08:00
22 changed files with 184 additions and 451 deletions

View File

@@ -46,7 +46,7 @@ jobs:
- name: 'Log in to the Container registry'
if: |-
${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }}
uses: 'docker/login-action@v3' # ratchet:exclude
with:
registry: '${{ env.REGISTRY }}'

View File

@@ -95,10 +95,19 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci
npm ci --prefer-offline --no-audit --progress=false
- name: 'Run formatter check'
run: |-
@@ -273,15 +282,24 @@ jobs:
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
- name: 'Build project'
run: |-
npm run build
- name: 'Install dependencies for testing'
run: |-
npm ci
- name: 'Run tests and generate reports'
env:
NO_COLOR: true

View File

@@ -28,10 +28,19 @@ jobs:
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci
npm ci --prefer-offline --no-audit --progress=false
- name: 'Build project'
run: |-
@@ -74,10 +83,19 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci
npm ci --prefer-offline --no-audit --progress=false
- name: 'Build project'
run: |-

View File

@@ -48,7 +48,7 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |
settings_json: |-
{
"maxSessionTurns": 25,
"coreTools": [
@@ -68,7 +68,7 @@ jobs:
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Use right tool to review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
2. Use shell command `echo` to check the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
3. Ignore any existing priorities or tags on the issue. Just report your findings.
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.

View File

@@ -36,7 +36,7 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |
run: |-
echo "🔍 Finding issues without labels..."
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
@@ -66,7 +66,7 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |
settings_json: |-
{
"maxSessionTurns": 25,
"coreTools": [
@@ -88,7 +88,7 @@ jobs:
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Use right tool to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
2. Use shell command `echo` to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
3. Review the issue title, body and any comments provided in the environment variables.
4. Ignore any existing priorities or tags on the issue.
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.

View File

@@ -16,7 +16,7 @@ on:
jobs:
review-pr:
if: >
if: |-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
@@ -59,7 +59,7 @@ jobs:
${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
run: |-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_NUMBER=${{ github.event.inputs.pr_number }}
else
@@ -82,7 +82,7 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
COMMENT_BODY: '${{ github.event.comment.body }}'
run: |
run: |-
PR_NUMBER=${{ github.event.issue.number }}
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Extract additional instructions from comment
@@ -110,22 +110,15 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |
settings_json: |-
{
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh pr view)",
"run_shell_command(gh pr diff)",
"run_shell_command(gh pr comment)",
"run_shell_command(cat)",
"run_shell_command(head)",
"run_shell_command(tail)",
"run_shell_command(grep)",
"run_shell_command",
"write_file"
],
"sandbox": false
}
prompt: |
prompt: |-
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.

View File

@@ -69,6 +69,11 @@ This guide provides solutions to common issues and debugging tips, including top
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN qwen`
- **CLI becomes unresponsive during interactive selection dialogs**
- **Issue:** When encountering selection dialogs with numbered options (such as shell command confirmation dialogs), the CLI appears "frozen" and does not respond to keyboard input.
- **Cause:** This was caused by multiple keyboard input handlers (`useKeypress` hooks) being active simultaneously, creating conflicts in terminal raw mode control.
- **Solution:** This issue has been fixed in recent versions. The keyboard input handling has been consolidated to prevent conflicts. If you encounter this issue, ensure you're using the latest version of Qwen Code.
- **DEBUG mode not working from project .env file**
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for the CLI.
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with the CLI behavior.

View File

@@ -35,7 +35,7 @@ describe('docsCommand', () => {
throw new Error('docsCommand must have an action.');
}
const docsUrl = 'https://github.com/QwenLM/qwen-code';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
await docsCommand.action(mockContext, '');
@@ -57,7 +57,7 @@ describe('docsCommand', () => {
// Simulate a sandbox environment
process.env.SANDBOX = 'gemini-sandbox';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
await docsCommand.action(mockContext, '');
@@ -80,7 +80,7 @@ describe('docsCommand', () => {
// Simulate the specific 'sandbox-exec' environment
process.env.SANDBOX = 'sandbox-exec';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
await docsCommand.action(mockContext, '');

View File

@@ -18,7 +18,7 @@ export const docsCommand: SlashCommand = {
description: 'open full Qwen Code documentation in your browser',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://github.com/QwenLM/qwen-code';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
context.ui.addItem(

View File

@@ -117,7 +117,7 @@ describe('memoryCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add [--global|--project] <text to remember>',
content: 'Usage: /memory add <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
@@ -132,7 +132,7 @@ describe('memoryCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory : "${fact}"`,
text: `Attempting to save to memory: "${fact}"`,
},
expect.any(Number),
);
@@ -143,61 +143,6 @@ describe('memoryCommand', () => {
toolArgs: { fact },
});
});
it('should handle --global flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this globally';
const result = addCommand.action(mockContext, `--global ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (global): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'global' },
});
});
it('should handle --project flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this for project';
const result = addCommand.action(mockContext, `--project ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (project): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'project' },
});
});
it('should return error if flag is provided but no fact follows', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, '--global ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add [--global|--project] <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
});
describe('/memory refresh', () => {
@@ -228,7 +173,7 @@ describe('memoryCommand', () => {
mockContext = createMockCommandContext({
services: {
config: mockConfig,
config: Promise.resolve(mockConfig),
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,

View File

@@ -7,11 +7,7 @@
import {
getErrorMessage,
loadServerHierarchicalMemory,
QWEN_DIR,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import os from 'os';
import fs from 'fs/promises';
import { MessageType } from '../types.js';
import {
CommandKind,
@@ -45,136 +41,24 @@ export const memoryCommand: SlashCommand = {
Date.now(),
);
},
subCommands: [
{
name: '--project',
description: 'Show project-level memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
const memoryContent = await fs.readFile(
projectMemoryPath,
'utf-8',
);
const messageContent =
memoryContent.trim().length > 0
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
: 'Project memory is currently empty.';
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Project memory file not found or is currently empty.',
},
Date.now(),
);
}
},
},
{
name: '--global',
description: 'Show global memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const globalMemoryPath = path.join(
os.homedir(),
QWEN_DIR,
'QWEN.md',
);
const globalMemoryContent = await fs.readFile(
globalMemoryPath,
'utf-8',
);
const messageContent =
globalMemoryContent.trim().length > 0
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
: 'Global memory is currently empty.';
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Global memory file not found or is currently empty.',
},
Date.now(),
);
}
},
},
],
},
{
name: 'add',
description:
'Add content to the memory. Use --global for global memory or --project for project memory.',
description: 'Add content to the memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /memory add [--global|--project] <text to remember>',
content: 'Usage: /memory add <text to remember>',
};
}
const trimmedArgs = args.trim();
let scope: 'global' | 'project' | undefined;
let fact: string;
// Check for scope flags
if (trimmedArgs.startsWith('--global ')) {
scope = 'global';
fact = trimmedArgs.substring('--global '.length).trim();
} else if (trimmedArgs.startsWith('--project ')) {
scope = 'project';
fact = trimmedArgs.substring('--project '.length).trim();
} else if (trimmedArgs === '--global' || trimmedArgs === '--project') {
// Flag provided but no text after it
return {
type: 'message',
messageType: 'error',
content:
'Usage: /memory add [--global|--project] <text to remember>',
};
} else {
// No scope specified, will be handled by the tool
fact = trimmedArgs;
}
if (!fact || fact.trim() === '') {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /memory add [--global|--project] <text to remember>',
};
}
const scopeText = scope ? `(${scope})` : '';
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory ${scopeText}: "${fact}"`,
text: `Attempting to save to memory: "${args.trim()}"`,
},
Date.now(),
);
@@ -182,67 +66,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: scope ? { fact, scope } : { fact },
toolArgs: { fact: args.trim() },
};
},
subCommands: [
{
name: '--project',
description: 'Add content to project-level memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --project <text to remember>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to project memory: "${args.trim()}"`,
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'project' },
};
},
},
{
name: '--global',
description: 'Add content to global memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --global <text to remember>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to global memory: "${args.trim()}"`,
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'global' },
};
},
},
],
},
{
name: 'refresh',
@@ -258,7 +84,7 @@ export const memoryCommand: SlashCommand = {
);
try {
const config = context.services.config;
const config = await context.services.config;
if (config) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(

View File

@@ -8,7 +8,6 @@ import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -31,15 +30,6 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => {
const { commands, onConfirm } = request;
useKeypress(
(key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {
onConfirm(item);
@@ -50,6 +40,10 @@ export const ShellConfirmationDialog: React.FC<
}
};
const handleEscape = () => {
onConfirm(ToolConfirmationOutcome.Cancel);
};
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Yes, allow once',
@@ -96,7 +90,12 @@ export const ShellConfirmationDialog: React.FC<
<Text>Do you want to proceed?</Text>
</Box>
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
<RadioButtonSelect
items={options}
onSelect={handleSelect}
onEscape={handleEscape}
isFocused
/>
</Box>
);
};

View File

@@ -20,7 +20,6 @@ import {
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -57,18 +56,11 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
const handleEscape = () => handleConfirm(ToolConfirmationOutcome.Cancel);
const handleCancel = () => handleConfirm(ToolConfirmationOutcome.Cancel);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;
@@ -283,6 +275,8 @@ export const ToolConfirmationMessage: React.FC<
<RadioButtonSelect
items={options}
onSelect={handleSelect}
onEscape={handleEscape}
onCancel={handleCancel}
isFocused={isFocused}
/>
</Box>

View File

@@ -34,6 +34,10 @@ export interface RadioButtonSelectProps<T> {
onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void;
/** Function called when escape key is pressed. */
onEscape?: () => void;
/** Function called when Ctrl+C is pressed. */
onCancel?: () => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
/** Whether to show the scroll arrows. */
@@ -55,6 +59,8 @@ export function RadioButtonSelect<T>({
initialIndex = 0,
onSelect,
onHighlight,
onEscape,
onCancel,
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
@@ -91,6 +97,18 @@ export function RadioButtonSelect<T>({
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Handle escape key
if (name === 'escape') {
onEscape?.();
return;
}
// Handle Ctrl+C
if (key.ctrl && name === 'c') {
onCancel?.();
return;
}
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
clearTimeout(numberInputTimer.current);

View File

@@ -1351,7 +1351,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Handle text content
if (choice.delta?.content) {
parts.push({ text: choice.delta.content });
if (typeof choice.delta.content === 'string') {
parts.push({ text: choice.delta.content });
}
}
// Handle tool calls - only accumulate during streaming, emit when complete
@@ -1371,10 +1373,36 @@ export class OpenAIContentGenerator implements ContentGenerator {
accumulatedCall.id = toolCall.id;
}
if (toolCall.function?.name) {
// If this is a new function name, reset the arguments
if (accumulatedCall.name !== toolCall.function.name) {
accumulatedCall.arguments = '';
}
accumulatedCall.name = toolCall.function.name;
}
if (toolCall.function?.arguments) {
accumulatedCall.arguments += toolCall.function.arguments;
// Check if we already have a complete JSON object
const currentArgs = accumulatedCall.arguments;
const newArgs = toolCall.function.arguments;
// If current arguments already form a complete JSON and new arguments start a new object,
// this indicates a new tool call with the same name
let shouldReset = false;
if (currentArgs && newArgs.trim().startsWith('{')) {
try {
JSON.parse(currentArgs);
// If we can parse current arguments as complete JSON and new args start with {,
// this is likely a new tool call
shouldReset = true;
} catch {
// Current arguments are not complete JSON, continue accumulating
}
}
if (shouldReset) {
accumulatedCall.arguments = newArgs;
} else {
accumulatedCall.arguments += newArgs;
}
}
}
}
@@ -1562,7 +1590,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
}
}
messageContent = textParts.join('');
messageContent = textParts.join('').trimEnd();
}
const choice: OpenAIChoice = {

View File

@@ -10,7 +10,7 @@ import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
import type { ChildProcess } from 'node:child_process';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
const hoistedMockExec = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', () => ({
@@ -157,7 +157,7 @@ describe('GitService', () => {
let gitConfigPath: string;
beforeEach(() => {
repoDir = path.join(homedir, QWEN_DIR, 'history', hash);
repoDir = path.join(homedir, GEMINI_DIR, 'history', hash);
gitConfigPath = path.join(repoDir, '.gitconfig');
});

View File

@@ -10,7 +10,7 @@ import * as os from 'os';
import { isNodeError } from '../utils/errors.js';
import { exec } from 'node:child_process';
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
export class GitService {
private projectRoot: string;
@@ -21,7 +21,7 @@ export class GitService {
private getHistoryDir(): string {
const hash = getProjectHash(this.projectRoot);
return path.join(os.homedir(), QWEN_DIR, 'history', hash);
return path.join(os.homedir(), GEMINI_DIR, 'history', hash);
}
async initialize(): Promise<void> {

View File

@@ -522,16 +522,13 @@ describe('MemoryTool', () => {
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
expect(result.title).toContain('Choose Memory Location');
expect(result.title).toContain('GLOBAL');
expect(result.title).toContain('PROJECT');
expect(result.fileName).toBe('QWEN.md');
expect(result.title).toBe('Choose Memory Storage Location');
expect(result.fileName).toBe('Memory Storage Options');
expect(result.fileDiff).toContain('Choose where to save this memory');
expect(result.fileDiff).toContain('Test fact');
expect(result.fileDiff).toContain('--- QWEN.md');
expect(result.fileDiff).toContain('+++ QWEN.md');
expect(result.fileDiff).toContain('+- Test fact');
expect(result.originalContent).toContain('scope: global');
expect(result.originalContent).toContain('INSTRUCTIONS:');
expect(result.fileDiff).toContain('Global:');
expect(result.fileDiff).toContain('Project:');
expect(result.originalContent).toBe('');
}
});
@@ -580,16 +577,13 @@ describe('MemoryTool', () => {
expect(description).toBe(`${expectedPath} (project)`);
});
it('should show choice prompt when scope is not specified', () => {
it('should default to global scope when scope is not specified', () => {
const params = { fact: 'Test fact' };
const invocation = memoryTool.build(params);
const description = invocation.getDescription();
const globalPath = path.join('~', '.qwen', 'QWEN.md');
const projectPath = path.join(process.cwd(), 'QWEN.md');
expect(description).toBe(
`CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`,
);
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(description).toBe(`${expectedPath} (global)`);
});
});
});

View File

@@ -199,12 +199,7 @@ class MemoryToolInvocation extends BaseToolInvocation<
private static readonly allowlist: Set<string> = new Set();
getDescription(): string {
if (!this.params.scope) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`;
}
const scope = this.params.scope;
const scope = this.params.scope || 'global';
const memoryFilePath = getMemoryFilePath(scope);
return `${tildeifyPath(memoryFilePath)} (${scope})`;
}
@@ -212,54 +207,26 @@ class MemoryToolInvocation extends BaseToolInvocation<
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolEditConfirmationDetails | false> {
// When scope is not specified, show a choice dialog defaulting to global
// If scope is not specified, prompt the user to choose
if (!this.params.scope) {
// Show preview of what would be added to global by default
const defaultScope = 'global';
const currentContent = await readMemoryFileContent(defaultScope);
const newContent = computeNewContent(currentContent, this.params.fact);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
const fileName = path.basename(getMemoryFilePath(defaultScope));
const choiceText = `Choose where to save this memory:
"${this.params.fact}"
Options:
- Global: ${globalPath} (shared across all projects)
- Project: ${projectPath} (current project only)
Preview of changes to be made to GLOBAL memory:
`;
const fileDiff =
choiceText +
Diff.createPatch(
fileName,
currentContent,
newContent,
'Current',
'Proposed (Global)',
DEFAULT_DIFF_OPTIONS,
);
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Choose Memory Location: GLOBAL (${globalPath}) or PROJECT (${projectPath})`,
fileName,
filePath: getMemoryFilePath(defaultScope),
fileDiff,
originalContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${currentContent}`,
newContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${newContent}`,
title: `Choose Memory Storage Location`,
fileName: 'Memory Storage Options',
filePath: '',
fileDiff: `Choose where to save this memory:\n\n"${this.params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
originalContent: '',
newContent: `Memory to save: ${this.params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Will be handled in createUpdatedParams
// This will be handled by the execution flow
},
};
return confirmationDetails;
}
// Only check allowlist when scope is specified
const scope = this.params.scope;
const memoryFilePath = getMemoryFilePath(scope);
const allowlistKey = `${memoryFilePath}_${scope}`;
@@ -312,25 +279,17 @@ Preview of changes to be made to GLOBAL memory:
};
}
// If scope is not specified and user didn't modify content, return error prompting for choice
if (!this.params.scope && !modified_by_user) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
const errorMessage = `Please specify where to save this memory:
Global: ${globalPath} (shared across all projects)
Project: ${projectPath} (current project only)`;
// If scope is not specified, prompt the user to choose
if (!this.params.scope) {
const errorMessage =
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
return {
llmContent: JSON.stringify({
success: false,
error: 'Please specify where to save this memory',
}),
returnDisplay: errorMessage,
llmContent: JSON.stringify({ success: false, error: errorMessage }),
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
};
}
const scope = this.params.scope || 'global';
const scope = this.params.scope;
const memoryFilePath = getMemoryFilePath(scope);
try {
@@ -488,88 +447,24 @@ export class MemoryTool
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
return {
getFilePath: (params: SaveMemoryParams) => {
// Determine scope from modified content or default
let scope = params.scope || 'global';
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
}
}
return getMemoryFilePath(scope);
},
getCurrentContent: async (params: SaveMemoryParams): Promise<string> => {
// Check if content starts with scope directive
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
const scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
const content = await readMemoryFileContent(scope);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`;
}
}
const scope = params.scope || 'global';
const content = await readMemoryFileContent(scope);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`;
},
getFilePath: (params: SaveMemoryParams) =>
getMemoryFilePath(params.scope || 'global'),
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
readMemoryFileContent(params.scope || 'global'),
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
let scope = params.scope || 'global';
// Check if modified content has scope directive
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
}
}
const scope = params.scope || 'global';
const currentContent = await readMemoryFileContent(scope);
const newContent = computeNewContent(currentContent, params.fact);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${newContent}`;
return computeNewContent(currentContent, params.fact);
},
createUpdatedParams: (
_oldContent: string,
modifiedProposedContent: string,
originalParams: SaveMemoryParams,
): SaveMemoryParams => {
// Parse user's scope choice from modified content
const scopeMatch = modifiedProposedContent.match(
/^scope:\s*(global|project)/i,
);
const scope = scopeMatch
? (scopeMatch[1].toLowerCase() as 'global' | 'project')
: 'global';
// Strip out the scope directive and instruction lines, keep only the actual memory content
const contentWithoutScope = modifiedProposedContent.replace(
/^scope:\s*(global|project)\s*\n/,
'',
);
const actualContent = contentWithoutScope
.replace(/^#[^\n]*\n/gm, '')
.replace(/^\s*\n/gm, '')
.trim();
return {
...originalParams,
scope,
modified_by_user: true,
modified_content: actualContent,
};
},
): SaveMemoryParams => ({
...originalParams,
modified_by_user: true,
modified_content: modifiedProposedContent,
}),
};
}
}

View File

@@ -8,7 +8,7 @@ import path from 'node:path';
import os from 'os';
import * as crypto from 'crypto';
export const QWEN_DIR = '.qwen';
export const GEMINI_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
const TMP_DIR_NAME = 'tmp';
const COMMANDS_DIR_NAME = 'commands';
@@ -181,7 +181,7 @@ export function getProjectHash(projectRoot: string): string {
*/
export function getProjectTempDir(projectRoot: string): string {
const hash = getProjectHash(projectRoot);
return path.join(os.homedir(), QWEN_DIR, TMP_DIR_NAME, hash);
return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash);
}
/**
@@ -189,7 +189,7 @@ export function getProjectTempDir(projectRoot: string): string {
* @returns The path to the user's commands directory.
*/
export function getUserCommandsDir(): string {
return path.join(os.homedir(), QWEN_DIR, COMMANDS_DIR_NAME);
return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME);
}
/**
@@ -198,5 +198,5 @@ export function getUserCommandsDir(): string {
* @returns The path to the project's commands directory.
*/
export function getProjectCommandsDir(projectRoot: string): string {
return path.join(projectRoot, QWEN_DIR, COMMANDS_DIR_NAME);
return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME);
}

View File

@@ -7,7 +7,7 @@
import path from 'node:path';
import { promises as fsp, existsSync, readFileSync } from 'node:fs';
import * as os from 'os';
import { QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js';
import { GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js';
interface UserAccounts {
active: string | null;
@@ -15,7 +15,7 @@ interface UserAccounts {
}
function getGoogleAccountsCachePath(): string {
return path.join(os.homedir(), QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME);
return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME);
}
async function readAccounts(filePath: string): Promise<UserAccounts> {

View File

@@ -8,10 +8,10 @@ import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
import { QWEN_DIR } from './paths.js';
import { GEMINI_DIR } from './paths.js';
const homeDir = os.homedir() ?? '';
const geminiDir = path.join(homeDir, QWEN_DIR);
const geminiDir = path.join(homeDir, GEMINI_DIR);
const installationIdFile = path.join(geminiDir, 'installation_id');
function ensureGeminiDirExists() {