Compare commits

..

5 Commits

19 changed files with 389 additions and 578 deletions

View File

@@ -23,8 +23,6 @@
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:native": "node scripts/build_native.js",
"build:native:all": "node scripts/build_native.js --all",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",

View File

@@ -434,6 +434,16 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',

View File

@@ -289,6 +289,12 @@ export default {
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
'Enable User Feedback': 'Benutzerfeedback aktivieren',
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Good: 'Gut',
'Not Sure Yet': 'Noch nicht sicher',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
'Screen Reader Mode': 'Bildschirmleser-Modus',
'IDE Mode': 'IDE-Modus',

View File

@@ -286,6 +286,12 @@ export default {
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
'Enable Welcome Back': 'Enable Welcome Back',
'Enable User Feedback': 'Enable User Feedback',
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Good: 'Good',
'Not Sure Yet': 'Not Sure Yet',
'Disable Loading Phrases': 'Disable Loading Phrases',
'Screen Reader Mode': 'Screen Reader Mode',
'IDE Mode': 'IDE Mode',

View File

@@ -289,6 +289,12 @@ export default {
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
'Enable Welcome Back': 'Включить приветствие при возврате',
'Enable User Feedback': 'Включить отзывы пользователей',
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Good: 'Хорошо',
'Not Sure Yet': 'Пока не уверен',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
'Screen Reader Mode': 'Режим программы чтения с экрана',
'IDE Mode': 'Режим IDE',

View File

@@ -277,6 +277,11 @@ export default {
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
'Enable Welcome Back': '启用欢迎回来',
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Good: '满意',
'Not Sure Yet': '暂不评价',
'Disable Loading Phrases': '禁用加载短语',
'Screen Reader Mode': '屏幕阅读器模式',
'IDE Mode': 'IDE 模式',

View File

@@ -45,6 +45,7 @@ import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -1173,6 +1174,19 @@ export const AppContainer = (props: AppContainerProps) => {
const nightly = props.version.includes('nightly');
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const dialogsVisible =
showWelcomeBackDialog ||
showWorkspaceMigrationDialog ||
@@ -1194,7 +1208,8 @@ export const AppContainer = (props: AppContainerProps) => {
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen;
isResumeDialogOpen ||
isFeedbackDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1292,6 +1307,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1382,6 +1399,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1422,6 +1441,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1457,6 +1480,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -0,0 +1,51 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { t } from '../i18n/index.js';
import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
const uiActions = useUIActions();
useKeypress(
(key) => {
if (key.name === 'escape') {
uiActions.closeFeedbackDialog();
} else if (key.name === '1') {
uiActions.submitFeedback(1);
} else if (key.name === '2') {
uiActions.submitFeedback(2);
} else if (key.name === '3') {
uiActions.submitFeedback(3);
} else if (key.name === '0') {
uiActions.closeFeedbackDialog();
}
},
{ isActive: uiState.isFeedbackDialogOpen },
);
if (!uiState.isFeedbackDialogOpen) {
return null;
}
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">1: </Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">2: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">3: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -35,6 +35,7 @@ import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { SessionPicker } from './SessionPicker.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -291,5 +292,9 @@ export const DialogManager = ({
);
}
if (uiState.isFeedbackDialogOpen) {
return <FeedbackDialog />;
}
return null;
};

View File

@@ -66,6 +66,10 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -126,6 +126,8 @@ export interface UIState {
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -0,0 +1,173 @@
import { useState, useCallback, useEffect } from 'react';
import {
type Config,
logUserFeedback,
UserFeedbackEvent,
type UserFeedbackRating,
} from '@qwen-code/qwen-code-core';
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
/**
* Check if there's an AI response after the last user message in the conversation history
*/
const hasAIResponseAfterLastUserMessage = (history: HistoryItem[]): boolean => {
// Find the last user message
let lastUserMessageIndex = -1;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].type === MessageType.USER) {
lastUserMessageIndex = i;
break;
}
}
// Check if there's any AI response (GEMINI message) after the last user message
if (lastUserMessageIndex !== -1) {
for (let i = lastUserMessageIndex + 1; i < history.length; i++) {
if (history[i].type === MessageType.GEMINI) {
return true;
}
}
}
return false;
};
/**
* Count the number of user messages in the conversation history
*/
const countUserMessages = (history: HistoryItem[]): number =>
history.filter((item) => item.type === MessageType.USER).length;
export interface UseFeedbackDialogProps {
config: Config;
settings: LoadedSettings;
streamingState: StreamingState;
history: HistoryItem[];
sessionStats: SessionStatsState;
}
export const useFeedbackDialog = ({
config,
settings,
streamingState,
history,
sessionStats,
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [feedbackShownForSession, setFeedbackShownForSession] = useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
setFeedbackShownForSession(true);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const submitFeedback = useCallback(
(rating: number) => {
// Calculate session duration and turn count
const sessionDurationMs =
Date.now() - sessionStats.sessionStartTime.getTime();
let lastUserMessageIndex = -1;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].type === MessageType.USER) {
lastUserMessageIndex = i;
break;
}
}
const turnCount =
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
sessionDurationMs,
turnCount,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, history, closeFeedbackDialog],
);
// Track when to show feedback dialog
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (streamingState === StreamingState.Idle && history.length > 0) {
const hasAIResponseAfterLastUser =
hasAIResponseAfterLastUserMessage(history);
const sessionDurationMs =
Date.now() - sessionStats.sessionStartTime.getTime();
// Get tool calls count and user messages count
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
const userMessagesCount = countUserMessages(history);
// Check if the session meets the minimum requirements:
// Either tool calls > 10 OR user messages > 5
const meetsMinimumRequirements =
toolCallsCount > MIN_TOOL_CALLS ||
userMessagesCount > MIN_USER_MESSAGES;
// Show feedback dialog if:
// 1. Telemetry is enabled (required for feedback submission)
// 2. User feedback is enabled in settings
// 3. There's an AI response after the last user message (real AI conversation)
// 4. Session duration > 10 seconds (meaningful interaction)
// 5. Not already shown for this session
// 6. Random chance (25% probability)
// 7. Meets minimum requirements (tool calls > 10 OR user messages > 5)
if (
config.getUsageStatisticsEnabled() && // Only show if telemetry is enabled
settings.merged.ui?.enableUserFeedback !== false && // Default to true if not set
hasAIResponseAfterLastUser &&
sessionDurationMs > 10000 && // 10 seconds minimum for meaningful interaction
!feedbackShownForSession &&
Math.random() < FEEDBACK_SHOW_PROBABILITY &&
meetsMinimumRequirements
) {
timeoutId = setTimeout(() => {
openFeedbackDialog();
}, 1000); // Delay to ensure user has time to see the completion
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [
streamingState,
history,
sessionStats,
isFeedbackDialogOpen,
feedbackShownForSession,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
};
};

View File

@@ -35,6 +35,7 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
export const EVENT_AUTH = 'qwen-code.auth';
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -45,6 +45,7 @@ export {
logNextSpeakerCheck,
logAuth,
logSkillLaunch,
logUserFeedback,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -65,6 +66,8 @@ export {
NextSpeakerCheckEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
UserFeedbackRating,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -38,6 +38,7 @@ import {
EVENT_INVALID_CHUNK,
EVENT_AUTH,
EVENT_SKILL_LAUNCH,
EVENT_USER_FEEDBACK,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -86,6 +87,7 @@ import type {
InvalidChunkEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -887,3 +889,32 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
};
logger.emit(logRecord);
}
export function logUserFeedback(
config: Config,
event: UserFeedbackEvent,
): void {
const uiEvent = {
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `User feedback: Rating ${event.rating} for session ${event.session_id}. Turn count: ${event.turn_count}. Duration: ${event.session_duration_ms}ms.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -39,6 +39,7 @@ import type {
ExtensionDisableEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
@@ -842,6 +843,23 @@ export class QwenLogger {
this.flushIfNeeded();
}
logUserFeedbackEvent(event: UserFeedbackEvent): void {
const rumEvent = this.createActionEvent('user', 'user_feedback', {
properties: {
session_id: event.session_id,
rating: event.rating,
session_duration_ms: event.session_duration_ms,
turn_count: event.turn_count,
model: event.model,
approval_mode: event.approval_mode,
prompt_id: event.prompt_id || '',
},
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logChatCompressionEvent(event: ChatCompressionEvent): void {
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
properties: {

View File

@@ -757,6 +757,44 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
}
}
export enum UserFeedbackRating {
BAD = 1,
FINE = 2,
GOOD = 3,
}
export class UserFeedbackEvent implements BaseTelemetryEvent {
'event.name': 'user_feedback';
'event.timestamp': string;
session_id: string;
rating: UserFeedbackRating;
session_duration_ms: number;
turn_count: number;
model: string;
approval_mode: string;
prompt_id?: string;
constructor(
session_id: string,
rating: UserFeedbackRating,
session_duration_ms: number,
turn_count: number,
model: string,
approval_mode: string,
prompt_id?: string,
) {
this['event.name'] = 'user_feedback';
this['event.timestamp'] = new Date().toISOString();
this.session_id = session_id;
this.rating = rating;
this.session_duration_ms = session_duration_ms;
this.turn_count = turn_count;
this.model = model;
this.approval_mode = approval_mode;
this.prompt_id = prompt_id;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -786,7 +824,8 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent;
| SkillLaunchEvent
| UserFeedbackEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -1,323 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distRoot = path.join(rootDir, 'dist', 'native');
const entryPoint = path.join(rootDir, 'packages', 'cli', 'index.ts');
const localesDir = path.join(
rootDir,
'packages',
'cli',
'src',
'i18n',
'locales',
);
const vendorDir = path.join(rootDir, 'packages', 'core', 'vendor');
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
const cliName = Object.keys(rootPackageJson.bin || {})[0] || 'qwen';
const version = rootPackageJson.version;
const TARGETS = [
{
id: 'darwin-arm64',
os: 'darwin',
arch: 'arm64',
bunTarget: 'bun-darwin-arm64',
},
{
id: 'darwin-x64',
os: 'darwin',
arch: 'x64',
bunTarget: 'bun-darwin-x64',
},
{
id: 'linux-arm64',
os: 'linux',
arch: 'arm64',
bunTarget: 'bun-linux-arm64',
},
{
id: 'linux-x64',
os: 'linux',
arch: 'x64',
bunTarget: 'bun-linux-x64',
},
{
id: 'linux-arm64-musl',
os: 'linux',
arch: 'arm64',
libc: 'musl',
bunTarget: 'bun-linux-arm64-musl',
},
{
id: 'linux-x64-musl',
os: 'linux',
arch: 'x64',
libc: 'musl',
bunTarget: 'bun-linux-x64-musl',
},
{
id: 'windows-x64',
os: 'windows',
arch: 'x64',
bunTarget: 'bun-windows-x64',
},
];
function getHostTargetId() {
const platform = process.platform;
const arch = process.arch;
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
if (platform === 'win32' && arch === 'x64') return 'windows-x64';
if (platform === 'linux' && arch === 'x64') {
return isMusl() ? 'linux-x64-musl' : 'linux-x64';
}
if (platform === 'linux' && arch === 'arm64') {
return isMusl() ? 'linux-arm64-musl' : 'linux-arm64';
}
return null;
}
function isMusl() {
if (process.platform !== 'linux') return false;
const report = process.report?.getReport?.();
return !report?.header?.glibcVersionRuntime;
}
function parseArgs(argv) {
const args = {
all: false,
list: false,
targets: [],
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--all') {
args.all = true;
} else if (arg === '--list-targets') {
args.list = true;
} else if (arg === '--target' && argv[i + 1]) {
args.targets.push(argv[i + 1]);
i += 1;
} else if (arg?.startsWith('--targets=')) {
const raw = arg.split('=')[1] || '';
args.targets.push(
...raw
.split(',')
.map((value) => value.trim())
.filter(Boolean),
);
}
}
return args;
}
function ensureBunAvailable() {
const result = spawnSync('bun', ['--version'], { stdio: 'pipe' });
if (result.error) {
console.error('Error: Bun is required to build native binaries.');
console.error('Install Bun from https://bun.sh and retry.');
process.exit(1);
}
}
function cleanNativeDist() {
fs.rmSync(distRoot, { recursive: true, force: true });
fs.mkdirSync(distRoot, { recursive: true });
}
function copyRecursiveSync(src, dest) {
if (!fs.existsSync(src)) {
return;
}
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
for (const entry of fs.readdirSync(src)) {
if (entry === '.DS_Store') continue;
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
if (stats.mode & 0o111) {
fs.chmodSync(dest, stats.mode);
}
}
}
function copyNativeAssets(targetDir, target) {
if (target.os === 'darwin') {
const sbFiles = findSandboxProfiles();
for (const file of sbFiles) {
fs.copyFileSync(file, path.join(targetDir, path.basename(file)));
}
}
copyVendorRipgrep(targetDir, target);
copyRecursiveSync(localesDir, path.join(targetDir, 'locales'));
}
function findSandboxProfiles() {
const matches = [];
const packagesDir = path.join(rootDir, 'packages');
const stack = [packagesDir];
while (stack.length) {
const current = stack.pop();
if (!current) break;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
} else if (entry.isFile() && entry.name.endsWith('.sb')) {
matches.push(entryPath);
}
}
}
return matches;
}
function copyVendorRipgrep(targetDir, target) {
if (!fs.existsSync(vendorDir)) {
console.warn(`Warning: Vendor directory not found at ${vendorDir}`);
return;
}
const vendorRipgrepDir = path.join(vendorDir, 'ripgrep');
if (!fs.existsSync(vendorRipgrepDir)) {
console.warn(`Warning: ripgrep directory not found at ${vendorRipgrepDir}`);
return;
}
const platform = target.os === 'windows' ? 'win32' : target.os;
const ripgrepTargetDir = path.join(
vendorRipgrepDir,
`${target.arch}-${platform}`,
);
if (!fs.existsSync(ripgrepTargetDir)) {
console.warn(`Warning: ripgrep binaries not found at ${ripgrepTargetDir}`);
return;
}
const destVendorRoot = path.join(targetDir, 'vendor');
const destRipgrepDir = path.join(destVendorRoot, 'ripgrep');
fs.mkdirSync(destRipgrepDir, { recursive: true });
const copyingFile = path.join(vendorRipgrepDir, 'COPYING');
if (fs.existsSync(copyingFile)) {
fs.copyFileSync(copyingFile, path.join(destRipgrepDir, 'COPYING'));
}
copyRecursiveSync(
ripgrepTargetDir,
path.join(destRipgrepDir, path.basename(ripgrepTargetDir)),
);
}
function buildTarget(target) {
const outputName = `${cliName}-${target.id}`;
const targetDir = path.join(distRoot, outputName);
const binDir = path.join(targetDir, 'bin');
const binaryName = target.os === 'windows' ? `${cliName}.exe` : cliName;
fs.mkdirSync(binDir, { recursive: true });
const buildArgs = [
'build',
'--compile',
'--target',
target.bunTarget,
entryPoint,
'--outfile',
path.join(binDir, binaryName),
];
const result = spawnSync('bun', buildArgs, { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`Bun build failed for ${target.id}`);
}
const packageJson = {
name: outputName,
version,
os: [target.os === 'windows' ? 'win32' : target.os],
cpu: [target.arch],
};
fs.writeFileSync(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2) + '\n',
);
copyNativeAssets(targetDir, target);
}
function main() {
if (!fs.existsSync(entryPoint)) {
console.error(`Entry point not found at ${entryPoint}`);
process.exit(1);
}
const args = parseArgs(process.argv.slice(2));
if (args.list) {
console.log(TARGETS.map((target) => target.id).join('\n'));
return;
}
ensureBunAvailable();
cleanNativeDist();
let selectedTargets = [];
if (args.all) {
selectedTargets = TARGETS;
} else if (args.targets.length > 0) {
selectedTargets = TARGETS.filter((target) =>
args.targets.includes(target.id),
);
} else {
const hostTargetId = getHostTargetId();
if (!hostTargetId) {
console.error(
`Unsupported host platform/arch: ${process.platform}/${process.arch}`,
);
process.exit(1);
}
selectedTargets = TARGETS.filter((target) => target.id === hostTargetId);
}
if (selectedTargets.length === 0) {
console.error('No matching targets selected.');
process.exit(1);
}
for (const target of selectedTargets) {
console.log(`\nBuilding native binary for ${target.id}...`);
buildTarget(target);
}
console.log('\n✅ Native build complete.');
}
main();

View File

@@ -1,251 +0,0 @@
# Standalone Release Spec (Bun Native + npm Fallback)
This document describes the target release design for shipping Qwen Code as native
binaries built with Bun, while retaining the existing npm JS bundle as a fallback
distribution. It is written as a migration-ready spec that bridges the current
release pipeline to the future dual-release system.
## Goal
Provide a CLI that:
- Runs as a standalone binary on Linux/macOS/Windows without requiring Node or Bun.
- Retains npm installation (global/local) as a JS-only fallback.
- Supports a curl installer that pulls the correct binary from GitHub Releases.
- Ships multiple variants (x64/arm64, musl/glibc where needed).
- Uses one release flow to produce all artifacts with a single tag/version.
## Non-Goals
- Replacing npm as a dev-time dependency manager.
- Shipping a single universal binary for all platforms.
- Supporting every architecture or OS outside the defined target matrix.
- Removing the existing Node/esbuild bundle.
## Current State (Baseline)
The current release pipeline:
- Bundles the CLI into `dist/cli.js` via esbuild.
- Uses `scripts/prepare-package.js` to create `dist/package.json`,
plus `vendor/`, `locales/`, and `*.sb` assets.
- Publishes `dist/` to npm as the primary distribution.
- Creates a GitHub Release and attaches only `dist/cli.js`.
- Uses `release.yml` for nightly/preview schedules and manual stable releases.
This spec extends the above pipeline; it does not replace it until the migration
phases complete.
## Target Architecture
### 1) Build Outputs
There are two build outputs:
1. Native binaries (Bun compile) for a target matrix.
2. Node-compatible JS bundle for npm fallback (existing `dist/` output).
Native build output for each target:
- dist/<name>/bin/<cli> (or .exe on Windows)
- dist/<name>/package.json (minimal package metadata)
Name encodes target:
- <cli>-linux-x64
- <cli>-linux-x64-musl
- <cli>-linux-arm64
- <cli>-linux-arm64-musl
- <cli>-darwin-arm64
- <cli>-darwin-x64
- <cli>-windows-x64
### 2) npm Distribution (JS Fallback)
Keep npm as a pure JS/TS CLI package that runs under Node/Bun. Do not ship or
auto-install native binaries through npm.
Implications:
- npm install always uses the JS implementation.
- No optionalDependencies for platform binaries.
- No postinstall symlink logic.
- No node shim that searches for a native binary.
### 3) GitHub Release Distribution (Primary)
Native binaries are distributed only via GitHub Releases and the curl installer:
- Archive each platform binary into a tar.gz (Linux) or zip (macOS/Windows).
- Attach archives to the GitHub Release.
- Provide a shell installer that detects target and downloads the correct archive.
## Detailed Implementation
### A) Target Matrix
Define a target matrix that includes OS, arch, and libc variants.
Target list (fixed set):
- darwin arm64
- darwin x64
- linux arm64 (glibc)
- linux x64 (glibc)
- linux arm64 musl
- linux x64 musl
- win32 x64
### B) Build Scripts
1. Native build script (new, e.g. `scripts/build-native.ts`)
Responsibilities:
- Remove native build output directory (keep npm `dist/` intact).
- For each target:
- Compute a target name.
- Compile using `Bun.build({ compile: { target: ... } })`.
- Write the binary to `dist/<name>/bin/<cli>`.
- Write a minimal `package.json` into `dist/<name>/`.
2. npm fallback build (existing)
Responsibilities:
- `npm run bundle` produces `dist/cli.js`.
- `npm run prepare:package` creates `dist/package.json` and copies assets.
Key details:
- Use Bun.build with compile.target = <bun-target> (e.g. bun-linux-x64).
- Include any extra worker/runtime files in entrypoints.
- Use define or execArgv to inject version/channel metadata.
- Use "windows" in archive naming even though the OS is "win32" internally.
Build-time considerations:
- Preinstall platform-specific native deps for bundling (example: bun install --os="_" --cpu="_" for dependencies with native bindings).
- Include worker assets in the compile entrypoints and embed their paths via define constants.
- Use platform-specific bunfs root paths when resolving embedded worker files.
- Set runtime execArgv flags for user-agent/version and system CA usage.
Target name example:
<cli>-<os>-<arch>[-musl]
Minimal package.json example:
{
"name": "<cli>-linux-x64",
"version": "<version>",
"os": ["linux"],
"cpu": ["x64"]
}
### C) Publish Script (new, optional)
Responsibilities:
1. Run the native build script.
2. Smoke test a local binary (`dist/<host>/bin/<cli> --version`).
3. Create GitHub Release archives.
4. Optionally build and push Docker image.
5. Publish npm package (JS-only fallback) as a separate step or pipeline.
Note: npm publishing is now independent of native binary publishing. It should not reference platform binaries.
### D) GitHub Release Installer (install)
A bash installer that:
1. Detects OS and arch.
2. Handles Rosetta (macOS) and musl detection (Alpine, ldd).
3. Builds target name and downloads from GitHub Releases.
4. Extracts to ~/.<cli>/bin.
5. Adds PATH unless --no-modify-path.
Supports:
- --version <version>
- --binary <path>
- --no-modify-path
Installer details to include:
- Require tar for Linux and unzip for macOS/Windows archives.
- Use "windows" in asset naming, not "win32".
- Prefer arm64 when macOS is running under Rosetta.
## CI/CD Flow (Dual Pipeline)
Release pipeline (native binaries):
1. Bump version.
2. Build binaries for the full target matrix.
3. Smoke test the host binary.
4. Create GitHub release assets.
5. Mark release as final (if draft).
Release pipeline (npm fallback):
1. Bump version (same tag).
2. Publish the JS-only npm package.
Release orchestration details to consider:
- Update all package.json version fields in the repo.
- Update any extension metadata or download URLs that embed version strings.
- Tag the release and create a GitHub Release draft that includes the binary assets.
### Workflow Mapping to Current Code
The existing `release.yml` workflow remains the orchestrator:
- Use `scripts/get-release-version.js` for version/tag selection.
- Keep tests and integration checks as-is.
- Add a native build matrix job that produces archives and uploads them to
the GitHub Release.
- Keep the npm publish step from `dist/` as the fallback.
- Ensure the same `RELEASE_TAG` is used for both native and npm outputs.
## Edge Cases and Pitfalls
- musl: Alpine requires musl binaries.
- Rosetta: macOS under Rosetta should prefer arm64 when available.
- npm fallback: ensure JS implementation is functional without native helpers.
- Path precedence: binary install should appear earlier in PATH than npm global bin if you want native to win by default.
- Archive prerequisites: users need tar/unzip depending on OS.
## Testing Plan
- Build all targets in CI.
- Run dist/<host>/bin/<cli> --version.
- npm install locally and verify CLI invocation.
- Run installer script on each OS or VM.
- Validate musl builds on Alpine.
## Migration Plan
Phase 1: Add native builds without changing npm
- [ ] Define target matrix with musl variants.
- [ ] Add native build script for Bun compile per target.
- [ ] Generate per-target package.json.
- [ ] Produce per-target archives and upload to GitHub Releases.
- [ ] Keep existing npm bundle publish unchanged.
Phase 2: Installer and docs
- [ ] Add curl installer for GitHub Releases.
- [ ] Document recommended install paths (native first).
- [ ] Add smoke tests for installer output.
Phase 3: Default install guidance and cleanup
- [ ] Update docs to recommend native install where possible.
- [ ] Decide whether npm stays equal or fallback-only in user docs.
## Implementation Checklist
- [ ] Keep `npm run bundle` + `npm run prepare:package` for JS fallback.
- [ ] Add `scripts/build-native.ts` for Bun compile targets.
- [ ] Add archive creation and asset upload in `release.yml`.
- [ ] Add an installer script with OS/arch/musl detection.
- [ ] Ensure tag/version parity across native and npm releases.