feat(cli): colorize /diff output via a themed Ink component

The /diff stats used to come back as a plain-text MessageActionReturn.
Pipes and ACP still get that, but in interactive terminals we now dispatch
a structured history item so the numbers can carry theme colors.

- packages/cli/src/ui/types.ts — new DiffRenderRow / DiffRenderModel /
  HistoryItemDiffStats, MessageType.DIFF_STATS.
- packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx — renders
  +N in theme.status.success (green), -M in theme.status.error (red), and
  the (new) / (binary) / (new, partial) markers in theme.text.secondary
  (dim). Column alignment matches the plain-text fallback.
- packages/cli/src/ui/components/HistoryItemDisplay.tsx — routes the new
  item type.
- packages/cli/src/ui/commands/diffCommand.ts — builds a DiffRenderModel
  once and fans out: interactive calls context.ui.addItem; other modes
  fall through to renderDiffModelText() for the plain-text path. Error
  and "clean tree" branches keep the existing info/error
  MessageActionReturn in every mode.
- Tests: existing diffCommand suite moved to an explicit non_interactive
  context (it was asserting text content); new interactive suite covers
  addItem dispatch and model shape; DiffStatsDisplay component tests
  cover the four row variants and the "…and N more" note.
This commit is contained in:
克竟 2026-04-24 17:45:42 +08:00
parent 54a0b2de16
commit e9a27210d7
9 changed files with 537 additions and 55 deletions

View file

@ -166,6 +166,8 @@ export default {
'{{count}} file changed, +{{added}} / -{{removed}}',
'{{count}} files changed, +{{added}} / -{{removed}}':
'{{count}} files changed, +{{added}} / -{{removed}}',
'{{count}} file changed': '{{count}} file changed',
'{{count}} files changed': '{{count}} files changed',
'…and {{hidden}} more (showing first {{shown}})':
'…and {{hidden}} more (showing first {{shown}})',
'(binary)': '(binary)',

View file

@ -161,6 +161,8 @@ export default {
'{{count}} 个文件变更,+{{added}} / -{{removed}}',
'{{count}} files changed, +{{added}} / -{{removed}}':
'{{count}} 个文件变更,+{{added}} / -{{removed}}',
'{{count}} file changed': '{{count}} 个文件变更',
'{{count}} files changed': '{{count}} 个文件变更',
'…and {{hidden}} more (showing first {{shown}})':
'…还有 {{hidden}} 个(仅显示前 {{shown}} 个)',
'(binary)': '(二进制)',

View file

@ -22,7 +22,24 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
});
function makeContextWithCwd(cwd = '/tmp/repo'): CommandContext {
// Non-interactive by default here because these tests assert on the
// plain-text `MessageActionReturn`; interactive mode dispatches via
// `context.ui.addItem` and is covered in a separate describe block.
return createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getWorkingDir: () => cwd,
getProjectRoot: () => cwd,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
});
}
function makeInteractiveContext(cwd = '/tmp/repo'): CommandContext {
return createMockCommandContext({
executionMode: 'interactive',
services: {
config: {
getWorkingDir: () => cwd,
@ -243,6 +260,86 @@ describe('diffCommand', () => {
});
});
describe('diffCommand interactive mode', () => {
let mockFetchGitDiff: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockFetchGitDiff = vi.mocked(fetchGitDiff);
});
it('dispatches a diff_stats history item instead of returning text', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 },
perFileStats: new Map([
['src/a.ts', { added: 5, removed: 2, isBinary: false }],
['src/b.ts', { added: 2, removed: 1, isBinary: false }],
]),
} satisfies GitDiffResult);
const result = await diffCommand.action(ctx, '');
expect(result).toBeUndefined();
expect(ctx.ui.addItem).toHaveBeenCalledTimes(1);
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
expect(call.type).toBe('diff_stats');
expect(call.model).toMatchObject({
filesCount: 2,
linesAdded: 7,
linesRemoved: 3,
hiddenCount: 0,
});
expect(call.model.rows).toHaveLength(2);
expect(call.model.rows[0]).toMatchObject({
filename: 'src/a.ts',
added: 5,
removed: 2,
isBinary: false,
isUntracked: false,
});
});
it('still returns a plain-text info message for the "clean tree" case', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 },
perFileStats: new Map(),
} satisfies GitDiffResult);
const result = await diffCommand.action(ctx, '');
expect(result).toMatchObject({ type: 'message', messageType: 'info' });
expect(ctx.ui.addItem).not.toHaveBeenCalled();
});
it('still returns an error MessageActionReturn when fetchGitDiff throws', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockRejectedValueOnce(new Error('boom'));
const result = await diffCommand.action(ctx, '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect(ctx.ui.addItem).not.toHaveBeenCalled();
});
it('propagates hiddenCount to the history item for fast-path results', async () => {
if (!diffCommand.action) throw new Error('Command has no action');
const ctx = makeInteractiveContext();
mockFetchGitDiff.mockResolvedValue({
stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 },
perFileStats: new Map([
['src/a.ts', { added: 1, removed: 0, isBinary: false }],
]),
} satisfies GitDiffResult);
await diffCommand.action(ctx, '');
const call = (ctx.ui.addItem as Mock).mock.calls[0][0];
expect(call.model.hiddenCount).toBe(59);
expect(call.model.rows).toHaveLength(1);
});
});
describe('diffCommand registration', () => {
it('declares all execution modes so it works in non-interactive and ACP', () => {
expect(diffCommand.supportedModes).toEqual([

View file

@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchGitDiff, type PerFileStats } from '@qwen-code/qwen-code-core';
import {
fetchGitDiff,
type GitDiffResult,
type PerFileStats,
} from '@qwen-code/qwen-code-core';
import {
CommandKind,
type CommandContext,
@ -12,10 +16,16 @@ import {
type SlashCommand,
} from './types.js';
import { t } from '../../i18n/index.js';
import {
MessageType,
type DiffRenderModel,
type DiffRenderRow,
type HistoryItemDiffStats,
} from '../types.js';
async function diffAction(
context: CommandContext,
): Promise<MessageActionReturn> {
): Promise<MessageActionReturn | void> {
const { config } = context.services;
if (!config) {
return {
@ -34,7 +44,7 @@ async function diffAction(
};
}
let result: Awaited<ReturnType<typeof fetchGitDiff>>;
let result: GitDiffResult | null;
try {
result = await fetchGitDiff(cwd);
} catch (error) {
@ -57,8 +67,7 @@ async function diffAction(
};
}
const { stats, perFileStats } = result;
if (stats.filesCount === 0) {
if (result.stats.filesCount === 0) {
return {
type: 'message',
messageType: 'info',
@ -66,77 +75,133 @@ async function diffAction(
};
}
const header =
stats.filesCount === 1
? t('{{count}} file changed, +{{added}} / -{{removed}}', {
count: String(stats.filesCount),
added: String(stats.linesAdded),
removed: String(stats.linesRemoved),
})
: t('{{count}} files changed, +{{added}} / -{{removed}}', {
count: String(stats.filesCount),
added: String(stats.linesAdded),
removed: String(stats.linesRemoved),
});
const rows = formatPerFile(perFileStats);
const hidden = stats.filesCount - perFileStats.size;
const capNote =
hidden > 0 && perFileStats.size > 0
? `\n ${t('…and {{hidden}} more (showing first {{shown}})', {
hidden: String(hidden),
shown: String(perFileStats.size),
})}`
: '';
const model = buildDiffRenderModel(result);
// Interactive path: dispatch a structured history item so `DiffStatsDisplay`
// can render with theme colors. Non-interactive / ACP stay on the
// plain-text MessageActionReturn path so pipes, logs, and transports that
// don't speak Ink still see legible output.
if (context.executionMode === 'interactive') {
const item: Omit<HistoryItemDiffStats, 'id'> = {
type: MessageType.DIFF_STATS,
model,
};
context.ui.addItem(item, Date.now());
return;
}
return {
type: 'message',
messageType: 'info',
content:
rows.length > 0 ? `${header}\n${rows.join('\n')}${capNote}` : header,
content: renderDiffModelText(model),
};
}
function formatPerFile(perFileStats: Map<string, PerFileStats>): string[] {
if (perFileStats.size === 0) return [];
/**
* Convert the raw `fetchGitDiff` result into a display-ready structure that
* both the Ink component and the plain-text renderer consume. Order of rows
* mirrors git's numstat output (which uses alphabetical or insertion order).
*/
export function buildDiffRenderModel(result: GitDiffResult): DiffRenderModel {
const rows: DiffRenderRow[] = [];
for (const [filename, s] of result.perFileStats) {
rows.push(toRow(filename, s));
}
const hiddenCount = Math.max(0, result.stats.filesCount - rows.length);
return {
filesCount: result.stats.filesCount,
linesAdded: result.stats.linesAdded,
linesRemoved: result.stats.linesRemoved,
rows,
hiddenCount,
};
}
function toRow(filename: string, s: PerFileStats): DiffRenderRow {
if (s.isBinary) {
return {
filename,
isBinary: true,
isUntracked: Boolean(s.isUntracked),
truncated: false,
};
}
return {
filename,
added: s.added,
removed: s.isUntracked ? 0 : s.removed,
isBinary: false,
isUntracked: Boolean(s.isUntracked),
truncated: Boolean(s.truncated),
};
}
/**
* Plain-text rendering of a `DiffRenderModel`. Used in non-interactive / ACP
* modes where no Ink renderer is available, and as the source of truth for
* the text column layout the Ink component mirrors.
*/
export function renderDiffModelText(model: DiffRenderModel): string {
const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model;
const header =
filesCount === 1
? t('{{count}} file changed, +{{added}} / -{{removed}}', {
count: String(filesCount),
added: String(linesAdded),
removed: String(linesRemoved),
})
: t('{{count}} files changed, +{{added}} / -{{removed}}', {
count: String(filesCount),
added: String(linesAdded),
removed: String(linesRemoved),
});
const lines = formatRowsText(rows);
const capNote =
hiddenCount > 0 && rows.length > 0
? `\n ${t('…and {{hidden}} more (showing first {{shown}})', {
hidden: String(hiddenCount),
shown: String(rows.length),
})}`
: '';
return lines.length > 0 ? `${header}\n${lines.join('\n')}${capNote}` : header;
}
function formatRowsText(rows: DiffRenderRow[]): string[] {
if (rows.length === 0) return [];
let maxAdded = 0;
let maxRemoved = 0;
for (const s of perFileStats.values()) {
if (s.isBinary) continue;
if (s.added > maxAdded) maxAdded = s.added;
if (s.removed > maxRemoved) maxRemoved = s.removed;
for (const r of rows) {
if (r.isBinary) continue;
if ((r.added ?? 0) > maxAdded) maxAdded = r.added ?? 0;
if ((r.removed ?? 0) > maxRemoved) maxRemoved = r.removed ?? 0;
}
const addWidth = String(maxAdded).length;
const remWidth = String(maxRemoved).length;
// Width of the `+X -Y` stat column so `~` (binary) rows line up with it.
const statColumnWidth = 1 + addWidth + 1 + 1 + remWidth;
const rows: string[] = [];
for (const [filename, s] of perFileStats) {
if (s.isBinary) {
const suffix = s.isUntracked
const out: string[] = [];
for (const r of rows) {
if (r.isBinary) {
const suffix = r.isUntracked
? ` ${t('(binary, new)')}`
: ` ${t('(binary)')}`;
rows.push(` ${padMarker('~', statColumnWidth)} ${filename}${suffix}`);
} else {
const added = `+${String(s.added).padStart(addWidth)}`;
const removed = `-${String(s.removed).padStart(remWidth)}`;
let suffix = '';
if (s.isUntracked) {
// `truncated` means we only counted part of a large new file — surface
// that so `+N` isn't read as the exact line count.
suffix = s.truncated ? ` ${t('(new, partial)')}` : ` ${t('(new)')}`;
}
rows.push(` ${added} ${removed} ${filename}${suffix}`);
out.push(` ${padMarker('~', statColumnWidth)} ${r.filename}${suffix}`);
continue;
}
const added = `+${String(r.added ?? 0).padStart(addWidth)}`;
const removed = `-${String(r.removed ?? 0).padStart(remWidth)}`;
let suffix = '';
if (r.isUntracked) {
suffix = r.truncated ? ` ${t('(new, partial)')}` : ` ${t('(new)')}`;
}
out.push(` ${added} ${removed} ${r.filename}${suffix}`);
}
return rows;
return out;
}
function padMarker(marker: string, width: number): string {
if (marker.length >= width) return marker;
const pad = ' '.repeat(width - marker.length);
return `${marker}${pad}`;
return `${marker}${' '.repeat(width - marker.length)}`;
}
export const diffCommand: SlashCommand = {

View file

@ -51,6 +51,7 @@ import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
import { MemorySavedMessage } from './messages/MemorySavedMessage.js';
import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js';
import { useCompactMode } from '../contexts/CompactModeContext.js';
interface HistoryItemDisplayProps {
@ -174,6 +175,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'stats' && (
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
)}
{itemForDisplay.type === 'diff_stats' && (
<DiffStatsDisplay model={itemForDisplay.model} />
)}
{itemForDisplay.type === 'model_stats' && (
<ModelStatsDisplay width={boxWidth} />
)}

View file

@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { DiffStatsDisplay } from './DiffStatsDisplay.js';
import type { DiffRenderModel } from '../../types.js';
function stripAnsi(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1b\[[0-9;]*m/g, '');
}
describe('DiffStatsDisplay', () => {
it('renders header and per-file rows aligned in columns', () => {
const model: DiffRenderModel = {
filesCount: 2,
linesAdded: 7,
linesRemoved: 3,
hiddenCount: 0,
rows: [
{
filename: 'src/a.ts',
added: 5,
removed: 2,
isBinary: false,
isUntracked: false,
truncated: false,
},
{
filename: 'src/b.ts',
added: 2,
removed: 1,
isBinary: false,
isUntracked: false,
truncated: false,
},
],
};
const { lastFrame } = render(<DiffStatsDisplay model={model} />);
const visible = stripAnsi(lastFrame() ?? '');
expect(visible).toContain('2 files changed');
expect(visible).toContain('+7');
expect(visible).toContain('-3');
const aRow = visible.split('\n').find((l) => l.endsWith('src/a.ts'))!;
const bRow = visible.split('\n').find((l) => l.endsWith('src/b.ts'))!;
// Columns align — "src/a.ts" and "src/b.ts" start at the same offset.
expect(aRow.indexOf('src/a.ts')).toBe(bRow.indexOf('src/b.ts'));
});
it('renders the (new) marker for untracked text files', () => {
const model: DiffRenderModel = {
filesCount: 1,
linesAdded: 3,
linesRemoved: 0,
hiddenCount: 0,
rows: [
{
filename: 'notes.md',
added: 3,
removed: 0,
isBinary: false,
isUntracked: true,
truncated: false,
},
],
};
const { lastFrame } = render(<DiffStatsDisplay model={model} />);
const visible = stripAnsi(lastFrame() ?? '');
expect(visible).toContain('notes.md');
expect(visible).toContain('(new)');
expect(visible).not.toContain('(new, partial)');
});
it('renders the (new, partial) marker for truncated untracked text files', () => {
const model: DiffRenderModel = {
filesCount: 1,
linesAdded: 10000,
linesRemoved: 0,
hiddenCount: 0,
rows: [
{
filename: 'big.log',
added: 10000,
removed: 0,
isBinary: false,
isUntracked: true,
truncated: true,
},
],
};
const visible = stripAnsi(
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
);
expect(visible).toContain('(new, partial)');
});
it('renders binary rows with a ~ marker and no +N/-M', () => {
const model: DiffRenderModel = {
filesCount: 1,
linesAdded: 0,
linesRemoved: 0,
hiddenCount: 0,
rows: [
{
filename: 'img.png',
isBinary: true,
isUntracked: false,
truncated: false,
},
],
};
const visible = stripAnsi(
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
);
const rowLine = visible.split('\n').find((l) => l.includes('img.png'))!;
expect(rowLine).toContain('~');
expect(rowLine).toContain('(binary)');
expect(rowLine).not.toMatch(/\+\d/);
});
it('renders the "…and N more" note when hiddenCount > 0', () => {
const model: DiffRenderModel = {
filesCount: 60,
linesAdded: 100,
linesRemoved: 20,
hiddenCount: 59,
rows: [
{
filename: 'src/a.ts',
added: 1,
removed: 0,
isBinary: false,
isUntracked: false,
truncated: false,
},
],
};
const visible = stripAnsi(
render(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
);
expect(visible).toContain('60 files changed');
expect(visible).toMatch(/59 more/);
});
});

View file

@ -0,0 +1,130 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type { DiffRenderModel, DiffRenderRow } from '../../types.js';
import { t } from '../../../i18n/index.js';
interface DiffStatsDisplayProps {
model: DiffRenderModel;
}
/**
* Colored rendering of `/diff` output for interactive mode. Mirrors the
* layout of the plain-text fallback (see `renderDiffModelText`) so the two
* modes stay visually aligned, but uses Ink primitives with `theme.status.*`
* tokens instead of baking ANSI into the text.
*/
export const DiffStatsDisplay: React.FC<DiffStatsDisplayProps> = ({
model,
}) => {
const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model;
// Reproduce the numeric-column alignment of the text renderer so the
// interactive and non-interactive outputs are visually interchangeable.
let maxAdded = 0;
let maxRemoved = 0;
for (const r of rows) {
if (r.isBinary) continue;
if ((r.added ?? 0) > maxAdded) maxAdded = r.added ?? 0;
if ((r.removed ?? 0) > maxRemoved) maxRemoved = r.removed ?? 0;
}
const addWidth = String(maxAdded).length;
const remWidth = String(maxRemoved).length;
const statColumnWidth = 1 + addWidth + 1 + 1 + remWidth;
const headerLabel =
filesCount === 1
? t('{{count}} file changed', { count: String(filesCount) })
: t('{{count}} files changed', { count: String(filesCount) });
return (
<Box flexDirection="column">
<Text>
<Text color={theme.text.primary}>{headerLabel}</Text>
<Text color={theme.text.secondary}>, </Text>
<Text color={theme.status.success}>+{linesAdded}</Text>
<Text color={theme.text.secondary}> / </Text>
<Text color={theme.status.error}>-{linesRemoved}</Text>
</Text>
{rows.map((row, i) => (
<DiffRow
key={`${i}-${row.filename}`}
row={row}
addWidth={addWidth}
remWidth={remWidth}
statColumnWidth={statColumnWidth}
/>
))}
{hiddenCount > 0 && rows.length > 0 && (
<Box>
<Text color={theme.text.secondary}>
{' '}
{t('…and {{hidden}} more (showing first {{shown}})', {
hidden: String(hiddenCount),
shown: String(rows.length),
})}
</Text>
</Box>
)}
</Box>
);
};
interface DiffRowProps {
row: DiffRenderRow;
addWidth: number;
remWidth: number;
statColumnWidth: number;
}
const DiffRow: React.FC<DiffRowProps> = ({
row,
addWidth,
remWidth,
statColumnWidth,
}) => {
if (row.isBinary) {
const marker = padRight('~', statColumnWidth);
const suffix = row.isUntracked ? t('(binary, new)') : t('(binary)');
return (
<Box>
<Text>
<Text color={theme.text.primary}>{' '}</Text>
<Text color={theme.text.secondary}>{marker}</Text>
<Text color={theme.text.primary}>{' '}</Text>
<Text color={theme.text.primary}>{row.filename}</Text>
<Text color={theme.text.secondary}> {suffix}</Text>
</Text>
</Box>
);
}
const added = String(row.added ?? 0).padStart(addWidth);
const removed = String(row.removed ?? 0).padStart(remWidth);
let suffix: string | null = null;
if (row.isUntracked) {
suffix = row.truncated ? t('(new, partial)') : t('(new)');
}
return (
<Box>
<Text>
<Text color={theme.text.primary}>{' '}</Text>
<Text color={theme.status.success}>+{added}</Text>
<Text color={theme.text.primary}> </Text>
<Text color={theme.status.error}>-{removed}</Text>
<Text color={theme.text.primary}>{' '}</Text>
<Text color={theme.text.primary}>{row.filename}</Text>
{suffix && <Text color={theme.text.secondary}> {suffix}</Text>}
</Text>
</Box>
);
};
function padRight(s: string, width: number): string {
return s.length >= width ? s : s + ' '.repeat(width - s.length);
}

View file

@ -175,6 +175,38 @@ export type HistoryItemStats = HistoryItemBase & {
duration: string;
};
/**
* Structured payload rendered by `/diff`. Kept as plain data (not React nodes)
* so the same model can feed both the Ink-based interactive display and the
* plain-text non-interactive / ACP output.
*/
export interface DiffRenderRow {
filename: string;
/** `undefined` for binary files; a line count (lower bound if `truncated`)
* otherwise. */
added?: number;
/** `undefined` for binary and untracked files. */
removed?: number;
isBinary: boolean;
isUntracked: boolean;
/** Only set for untracked text files that exceeded the read cap. */
truncated: boolean;
}
export interface DiffRenderModel {
filesCount: number;
linesAdded: number;
linesRemoved: number;
rows: DiffRenderRow[];
/** `filesCount - rows.length` when the per-file cap truncated the listing. */
hiddenCount: number;
}
export type HistoryItemDiffStats = HistoryItemBase & {
type: 'diff_stats';
model: DiffRenderModel;
};
export type HistoryItemModelStats = HistoryItemBase & {
type: 'model_stats';
};
@ -492,7 +524,8 @@ export type HistoryItemWithoutId =
| HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop
| HistoryItemStopHookSystemMessage
| HistoryItemDoctor;
| HistoryItemDoctor
| HistoryItemDiffStats;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -521,6 +554,7 @@ export enum MessageType {
ARENA_SESSION_COMPLETE = 'arena_session_complete',
INSIGHT_PROGRESS = 'insight_progress',
BTW = 'btw',
DIFF_STATS = 'diff_stats',
}
export interface InsightProgressProps {

View file

@ -1,5 +1,5 @@
{
"generatedAt": "2026-04-24T09:04:13.440Z",
"generatedAt": "2026-04-24T09:44:54.528Z",
"keys": [
" Models: Qwen latest models\n",
" qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)",