mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-02 12:15:06 +00:00
fix(core): emit auth URL as OSC 8 hyperlink instead of hard-wrapping
When the Qwen OAuth device flow falls back to the non-interactive message (--no-browser or non-TUI contexts), the auth URL was hard-wrapped at character boundaries to fit a fixed-width ASCII box. This broke the URL across multiple lines, making it impossible to click or copy-paste as a single link — especially over SSH. Add supportsOsc8Hyperlinks() to detect terminal OSC 8 support (iTerm2, WezTerm, Kitty, VS Code, Windows Terminal, VTE, Alacritty, etc.) and osc8Hyperlink() to wrap URLs in OSC 8 escape sequences. When supported, the URL is emitted as a single clickable hyperlink; otherwise the existing ASCII box with hard-wrapping is preserved as a fallback. Fixes #6428 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
7e0e79b6bc
commit
8aecb37211
2 changed files with 246 additions and 6 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
QwenOAuth2Client,
|
||||
showFallbackMessage,
|
||||
type DeviceAuthorizationResponse,
|
||||
type DeviceTokenResponse,
|
||||
type ErrorData,
|
||||
|
|
@ -2387,3 +2388,172 @@ describe('Constants and Configuration', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFallbackMessage', () => {
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stderrWriteSpy: any;
|
||||
let originalIsTTY: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
originalIsTTY = process.stderr.isTTY ?? false;
|
||||
stderrWriteSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
// Reset env
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrWriteSpy.mockRestore();
|
||||
process.env = ORIGINAL_ENV;
|
||||
// Restore isTTY
|
||||
if (originalIsTTY) {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getOutput(): string {
|
||||
return stderrWriteSpy.mock.calls.map((c: [string]) => c[0]).join('');
|
||||
}
|
||||
|
||||
it('emits OSC 8 hyperlink when terminal supports it (iTerm.app)', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TERM_PROGRAM'] = 'iTerm.app';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain(
|
||||
'\x1b]8;;https://chat.qwen.ai/device?code=ABC123\x07',
|
||||
);
|
||||
expect(output).toContain('\x1b]8;;\x07');
|
||||
// URL appears as a single line within the OSC 8 envelope
|
||||
expect(output).toContain(
|
||||
'https://chat.qwen.ai/device?code=ABC123\x1b]8;;\x07',
|
||||
);
|
||||
});
|
||||
|
||||
it('hard-wraps URL when terminal does not support OSC 8 (non-TTY)', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
delete process.env['TERM_PROGRAM'];
|
||||
delete process.env['FORCE_HYPERLINK'];
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).not.toContain('\x1b]8;;');
|
||||
// URL is present as plain text
|
||||
expect(output).toContain('https://chat.qwen.ai/device?code=ABC123');
|
||||
});
|
||||
|
||||
it('does not emit OSC 8 when QWEN_DISABLE_HYPERLINKS=1', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TERM_PROGRAM'] = 'iTerm.app';
|
||||
process.env['QWEN_DISABLE_HYPERLINKS'] = '1';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).not.toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('respects FORCE_HYPERLINK=1 even in tmux', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0';
|
||||
process.env['FORCE_HYPERLINK'] = '1';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('refuses OSC 8 in tmux without FORCE_HYPERLINK', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0';
|
||||
delete process.env['FORCE_HYPERLINK'];
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).not.toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('detects Windows Terminal via WT_SESSION', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['WT_SESSION'] = 'xxx';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('detects Kitty via TERM=xterm-kitty', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TERM'] = 'xterm-kitty';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('detects VS Code via TERM_PROGRAM=vscode', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain('\x1b]8;;');
|
||||
});
|
||||
|
||||
it('still renders the ASCII box with title and instructions', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env['TERM_PROGRAM'] = 'iTerm.app';
|
||||
|
||||
showFallbackMessage('https://chat.qwen.ai/device?code=ABC123');
|
||||
|
||||
const output = getOutput();
|
||||
expect(output).toContain('Qwen OAuth Device Authorization');
|
||||
expect(output).toContain('Please visit the following URL');
|
||||
expect(output).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -701,16 +701,81 @@ export async function getQwenOAuthClient(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether stderr's terminal supports OSC 8 hyperlinks. A minimal but
|
||||
* safe subset of the detection logic in `packages/cli/src/ui/utils/osc8.ts` —
|
||||
* enough to cover the common terminals (iTerm2, WezTerm, Kitty, VS Code,
|
||||
* Windows Terminal, GNOME Terminal/VTE) while refusing for multiplexers
|
||||
* (tmux/screen) and non-TTY contexts unless `FORCE_HYPERLINK` is set.
|
||||
*/
|
||||
function supportsOsc8Hyperlinks(): boolean {
|
||||
const env = process.env;
|
||||
if (env['QWEN_DISABLE_HYPERLINKS'] === '1') return false;
|
||||
if (!process.stderr?.isTTY) return false;
|
||||
const force = env['FORCE_HYPERLINK'];
|
||||
if (force !== undefined) return force !== '0';
|
||||
if (env['CI']) return false;
|
||||
if (env['TMUX'] || env['STY']) return false;
|
||||
if (env['WT_SESSION']) return true;
|
||||
if (env['KITTY_WINDOW_ID'] || env['TERM'] === 'xterm-kitty') return true;
|
||||
if (env['DOMTERM']) return true;
|
||||
if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') {
|
||||
return true;
|
||||
}
|
||||
if (env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm') return true;
|
||||
if (env['TERM_PROGRAM']) {
|
||||
switch (env['TERM_PROGRAM']) {
|
||||
case 'iTerm.app':
|
||||
case 'WezTerm':
|
||||
case 'vscode':
|
||||
case 'ghostty':
|
||||
case 'mintty':
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (env['VTE_VERSION']) {
|
||||
const v = parseInt(env['VTE_VERSION'], 10);
|
||||
if (Number.isFinite(v) && v >= 5000 && v !== 5000) return true;
|
||||
}
|
||||
if (
|
||||
env['TERM'] === 'alacritty' ||
|
||||
env['ALACRITTY_LOG'] !== undefined ||
|
||||
env['ALACRITTY_WINDOW_ID'] !== undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a URL in an OSC 8 hyperlink escape sequence. BEL (\x07) terminates
|
||||
* the OSC — more broadly supported than ST (ESC \\). Control characters
|
||||
* are stripped from the URL to prevent breaking the OSC envelope.
|
||||
*/
|
||||
function osc8Hyperlink(url: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const safeUrl = url.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');
|
||||
return `\x1b]8;;${safeUrl}\x07${safeUrl}\x1b]8;;\x07`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a formatted box with OAuth device authorization URL.
|
||||
* Uses process.stderr.write() to ensure the auth URL is always visible to users,
|
||||
* especially in non-interactive mode. Using stderr prevents corruption of
|
||||
* structured JSON output (which goes to stdout) and follows the standard Unix
|
||||
* convention of user-facing messages to stderr.
|
||||
*
|
||||
* When the terminal supports OSC 8 hyperlinks, the URL is emitted as a single
|
||||
* clickable link instead of being hard-wrapped across multiple lines. This is
|
||||
* especially important over SSH, where wrapped URLs are impossible to
|
||||
* copy-paste as a single link.
|
||||
*/
|
||||
function showFallbackMessage(verificationUriComplete: string): void {
|
||||
export function showFallbackMessage(verificationUriComplete: string): void {
|
||||
const title = 'Qwen OAuth Device Authorization';
|
||||
const url = verificationUriComplete;
|
||||
const useOsc8 = supportsOsc8Hyperlinks();
|
||||
const minWidth = 70;
|
||||
const maxWidth = 80;
|
||||
const boxWidth = Math.min(Math.max(title.length + 4, minWidth), maxWidth);
|
||||
|
|
@ -786,11 +851,16 @@ function showFallbackMessage(verificationUriComplete: string): void {
|
|||
|
||||
process.stderr.write(emptyLine + '\n');
|
||||
|
||||
// Write URL
|
||||
for (const line of urlLines) {
|
||||
process.stderr.write(
|
||||
'| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n',
|
||||
);
|
||||
// Write URL — as a single OSC 8 clickable hyperlink when supported,
|
||||
// or hard-wrapped across lines as a fallback.
|
||||
if (useOsc8) {
|
||||
process.stderr.write('| ' + osc8Hyperlink(url) + ' |\n');
|
||||
} else {
|
||||
for (const line of urlLines) {
|
||||
process.stderr.write(
|
||||
'| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write(emptyLine + '\n');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue