mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
* fix(mcp): render OAuth URL as OSC 8 hyperlink so it stays clickable when wrapped Closes #3470. The MCP OAuth flow previously pushed the authorization URL through the generic display-message list, where Ink rendered it as plain text. When the URL exceeded the terminal width it got hard-wrapped into the message buffer, and most terminals could no longer detect it as a single hyperlink (cmd/ctrl+click did nothing, selecting it pulled in extra whitespace). Render the URL as an OSC 8 hyperlink in AuthenticateStep instead, and stop duplicating it through the display-message stream when an event emitter is available. Terminals that support OSC 8 (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code, GNOME Terminal, …) now treat the URL as a single clickable link even when it visually wraps; terminals without OSC 8 support ignore the escapes and fall back to the existing "press c to copy" affordance. * fix(mcp): pre-split OAuth URL so every wrapped line stays clickable Wrapping the whole URL in a single OSC 8 hyperlink and letting Ink / wrap-ansi break the line produced two bugs observed in iTerm2 etc.: only the first visible segment was a hyperlink (wrap-ansi re-emits SGR codes across wraps but does not re-open OSC 8 links), and the remaining URL characters overflowed past the dialog border because wrap-ansi was unable to break the unbroken URL token within the container width. Manually slice the URL into chunks of `columns - 8` characters (MCPManagementDialog's container width) and render each chunk as its own OSC 8 hyperlink with `wrap="truncate"`. Every visible line now carries a complete hyperlink pointing at the same URL, and no line exceeds the container width. * fix(mcp): terminate OSC 8 hyperlinks with BEL so Ink preserves them Ink's renderer tokenizes text through @alcalzone/ansi-tokenize, which only recognizes OSC 8 hyperlink escapes terminated with BEL (\x07). The ST terminator (ESC \\) we were using is valid per the OSC 8 spec but the tokenizer falls through and treats the escape bytes as regular characters. That explains the two symptoms seen after the previous fix: - Only the first URL segment rendered as a clickable hyperlink. The rest of the lines had their opening \\x1b]8;; bytes tokenized as chars, so their hyperlink wrap was lost. - The dialog's right border disappeared because the mangled escape bytes consumed grid cells, pushing the container width past `columns - 8` and shoving the border off-screen. Switch the helper to the BEL-terminated form. Ink now sees each line's OSC 8 wrap as a proper zero-width code, every wrapped line stays clickable, and the border is no longer displaced. * fix(mcp): render OAuth URL via <Static> as a single unwrapped line The per-line OSC 8 approach didn't make lines past the first clickable in real terminals. Root cause: Ink's renderer runs text through @alcalzone/ansi-tokenize, which: - Only accepts OSC 8 sequences with empty params (`\x1b]8;;URL\x07`). Any `id=` form is parsed as a bogus SGR code and the remainder leaks out as visible characters. Without an `id=` grouping parameter, terminals like iTerm2 don't reliably stitch adjacent OSC 8 escapes together as one hyperlink. - Re-emits styles per Ink row via styledCharsToString, so even when each slice carried a self-contained OSC 8 wrap, terminals still treated each visual line as an independent hyperlink that only the first row reliably activated. Emit the URL through Ink's `<Static>` component instead, inside a `<Box width={url.length}>`. Ink sees a single logical line that doesn't need wrapping, so it hands the terminal one OSC 8 open, the whole URL, and one close. The terminal then soft-wraps that line visually, and the OSC 8 hyperlink state is carried across every wrap — every visible line is clickable. `<Static>` writes once above the dynamic dialog (scrollback-safe) and isn't touched by re-renders, which also avoids the flicker we'd get from repeatedly re-emitting the escape sequence inside the live tree. * fix(mcp): render OAuth URL as live row so it clears on dialog dismissal The previous <Static> emission made the URL stay permanently in the scrollback after the OAuth flow finished — e.g. after the dialog was dismissed the URL was still sitting above the prompt. Switch to a normal (live) Ink row: a Box sized to the URL length holding a single OSC 8 wrapped Text. Ink doesn't wrap the row (maxWidth == content width), so it hands log-update one long line; log-update's wrap-ansi pass then wraps it at terminal width and re-emits the OSC 8 escape at every wrap boundary, so every visible wrapped line is clickable. Because this is a regular child of the dialog, log-update tracks its height and erases it cleanly when the AuthenticateStep unmounts (auth succeeds / user backs out / dialog closes). * fix(mcp): pre-split OAuth URL so the live row clears cleanly The wide-Box live approach left dialog fragments in the scrollback: Ink ships its own log-update.js (packages/cli/.../ink/build/log-update.js) which counts erase height with output.split('\n').length and does NOT run wrap-ansi. A single Ink row that exceeds terminal width wraps visually but the erase still covers only one terminal line, so authState transitions (auth success, Esc-to-back, dialog dismiss) leave the top rows of the previous frame behind. Go back to pre-slicing the URL into chunks sized to the dialog content width (columns - 8) and rendering each chunk as its own Ink row with its own OSC 8 wrap. Log-update's row count then matches the visible row count, so erase is clean on every transition. Terminals that group adjacent OSC 8 sequences will still treat the whole URL as clickable; those that don't at least keep the first slice clickable, and the existing "press c to copy" affordance covers the rest. * fix(mcp): commit to Static-rendered URL outside the dialog Stop flip-flopping between in-box and out-of-box URL rendering. Every in-box attempt hit one of two walls: - Per-slice OSC 8 rows: each Ink row is its own self-contained hyperlink, but some terminals (seen with the reporter's) only register the first adjacent OSC 8 without an id= parameter as clickable. Ink's @alcalzone/ansi-tokenize rejects OSC 8 with params, so id= grouping is not deliverable. - Wide-Box overflow rows: the single OSC 8 wrap keeps every wrapped line clickable because the hyperlink state persists across the terminal's soft-wraps, but Ink ships its own log-update.js that counts erase height by output.split('\n').length and never runs wrap-ansi. When the row visually wraps but Ink counts it as 1 row, transitions (auth success / Esc / dismiss) erase too few lines and leave dialog fragments in the scrollback. Render the URL through <Static> above the dialog: it writes once, outside log-update's tracking, so the terminal soft-wraps a single OSC 8 hyperlink and every visible line stays clickable. The trade-off is that the URL stays in the scrollback after the dialog dismisses (Static is append-only); that is acceptable given the URL is no longer sensitive once auth has completed, and it avoids the click-failure and residue problems of the other approaches. * fix(mcp): print OAuth URL via useStdout, erase on unmount Drop <Static> (which persisted the URL in the scrollback forever) and print the authorization URL directly with Ink's `write` (useStdout) instead. Ink's writeToStdout clears the live frame, writes our bytes into the scrollback, and re-renders the frame below, so the URL goes out in a single OSC 8 hyperlink sequence and the terminal's soft-wrap preserves the hyperlink state across every wrapped row — every visible line stays clickable. On unmount (auth success, Esc, dialog dismiss) we use the same `write` path to push a cursor-up + eraseLines sequence that removes the URL rows (plus the leading/trailing blank separators) before log-update redraws the now-smaller live frame. Net effect: URL shows above the dialog while authenticating, disappears cleanly when the dialog goes away, and every wrapped line is clickable throughout. * fix(mcp): period-terminate prompt and restore wrap warning Now that the OAuth URL renders above the dialog (outside the message list), the in-dialog prompt no longer leads into the URL on the next line — rename the i18n key from "…into your browser:" to "…into your browser." and re-add the "Make sure to copy the COMPLETE URL — it may wrap across multiple lines." warning that was dropped when the URL was first moved out of displayMessage. Translations in de/en/fr/ja/pt/ ru/zh are updated to match and to point at the URL "above" rather than "following". * fix(mcp): correct OAuth URL erase count on unmount The previous logic wrote the URL as `\n${URL}\n` (leading + trailing newlines) and erased `urlVisualLines + 2` rows on unmount, but the leading blank and the trailing "\n" don't both occupy their own rows — the trailing newline just moves the cursor to where the dynamic UI is re-rendered. For a typical URL whose length isn't an exact multiple of the terminal width this left the erase off-by-one and wiped a row above the dialog (e.g. a piece of the command prompt). Drop the leading `\n` (no real visual benefit) and compute the erase count as `urlVisualLines + (autoWrapOverflow ? 1 : 0)`. The overflow term handles the aligned edge case where the terminal auto-wraps past the last URL char, leaving a blank row between URL and re-rendered dynamic UI that also needs erasing. Also drop the stale comment about Ink's ansi-tokenize restricting OSC 8 terminator choice — we now bypass Ink's tokenizer via useStdout, so BEL is just the more compatible terminator. * fix(mcp): pass OAuth URL hyperlink through multiplexer wrapper Inside tmux or GNU screen the raw OSC 8 hyperlink escape is intercepted by the multiplexer and never reaches the host terminal — users see the URL as plain text, exactly the bug this PR is trying to fix. The existing `wrapForMultiplexer` helper (already used for OSC 52 clipboard writes) wraps the sequence in a DCS passthrough envelope that tmux / screen forward to the host. Apply the same helper to `osc8Hyperlink` so tmux / screen users get clickable links for every wrapped line as well. Outside a multiplexer the helper is a no-op, so native terminals are unchanged. Also note in a comment that the captured `stdout.columns` goes stale if the terminal is resized during the OAuth flow; this is acceptable for a sub-minute flow on ASCII-only authorization URLs. * docs(mcp): note tmux 3.3+ allow-passthrough requirement * fix(mcp): render OAuth URL inside dialog box Replace the useStdout().write + cursor-up/eraseLines scrollback approach with an in-dialog <Box><Text>{osc8Hyperlink(url)}</Text></Box>. Removes the Ink dynamic-frame interleave and the column-width erase bookkeeping; the URL is owned by the dialog, so it disappears with it. * refactor(mcp): drop redundant Fragment around single Text * revert(mcp): restore original OAuth prompt wording URL now renders inside the dialog box, so the "copy and paste this URL into your browser:" prompt no longer needs the period-terminated / "URL above" rewording. Revert the i18n keys and localized strings; keep the event-driven dispatch so the URL isn't also pushed through displayMessage (which would double-render in the UI). * fix(mcp): sanitize URL/label before embedding in OSC 8 sequence An unescaped \x07 (BEL) or \x1b (ESC) in the URL or label would terminate the OSC 8 envelope early and let the tail bytes through as interpretable terminal escapes. authUrl is normally built via URL.toString() which percent-encodes controls, but the authorization endpoint itself comes from server-controlled OAuth discovery, so treat the input as untrusted and strip C0 + DEL before splicing.
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import { theme } from '../../../semantic-colors.js';
|
|
import { useKeypress } from '../../../hooks/useKeypress.js';
|
|
import { t } from '../../../../i18n/index.js';
|
|
import type { AuthenticateStepProps } from '../types.js';
|
|
import { useConfig } from '../../../contexts/ConfigContext.js';
|
|
import {
|
|
MCPOAuthProvider,
|
|
MCPOAuthTokenStorage,
|
|
getErrorMessage,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
|
|
import { appEvents, AppEvent } from '../../../../utils/events.js';
|
|
|
|
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
|
|
|
|
const AUTO_BACK_DELAY_MS = 2000;
|
|
const COPY_FEEDBACK_MS = 2000;
|
|
|
|
/**
|
|
* Wrap an OSC sequence for terminal multiplexers so the host terminal
|
|
* receives it. tmux requires a DCS passthrough with inner ESCs doubled;
|
|
* GNU screen uses a plain DCS envelope. Note: tmux 3.3+ defaults
|
|
* `allow-passthrough` to off — users on default configs will not see
|
|
* the hyperlink until they set `set -g allow-passthrough on`.
|
|
*/
|
|
function wrapForMultiplexer(osc: string): string {
|
|
if (process.env['TMUX']) {
|
|
return `\x1bPtmux;${osc.split('\x1b').join('\x1b\x1b')}\x1b\\`;
|
|
}
|
|
if (process.env['STY']) {
|
|
return `\x1bP${osc}\x1b\\`;
|
|
}
|
|
return osc;
|
|
}
|
|
|
|
/**
|
|
* Strip C0 control characters and DEL so an untrusted string can be safely
|
|
* embedded inside an OSC escape. Without this a `\x07` (BEL) or `\x1b` (ESC)
|
|
* in the input would prematurely terminate the OSC sequence and leak the
|
|
* tail bytes to the terminal as interpretable escape codes.
|
|
*/
|
|
function sanitizeForOsc(s: string): string {
|
|
// eslint-disable-next-line no-control-regex
|
|
return s.replace(/[\x00-\x1f\x7f]/g, '');
|
|
}
|
|
|
|
/**
|
|
* Wrap a URL in an OSC 8 hyperlink escape sequence. Supported terminals
|
|
* (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code, GNOME Terminal, …)
|
|
* render it as a clickable link; terminals without OSC 8 support ignore
|
|
* the escapes and print the raw text. BEL (\x07) terminates the OSC
|
|
* sequence — more broadly supported than ST (ESC \\).
|
|
*
|
|
* Inside tmux / screen the OSC sequence is wrapped in a DCS passthrough
|
|
* envelope (see `wrapForMultiplexer`) so the multiplexer forwards it to
|
|
* the host terminal instead of eating it.
|
|
*/
|
|
function osc8Hyperlink(url: string, label = url): string {
|
|
const safeUrl = sanitizeForOsc(url);
|
|
const safeLabel = sanitizeForOsc(label);
|
|
return wrapForMultiplexer(`\x1b]8;;${safeUrl}\x07${safeLabel}\x1b]8;;\x07`);
|
|
}
|
|
|
|
/**
|
|
* Copy a string to the user's clipboard using the OSC 52 terminal escape
|
|
* sequence. Works through SSH and most web terminals (iTerm2, Windows
|
|
* Terminal, xterm.js-based emulators) without spawning a subprocess.
|
|
* Returns true if the sequence was written to a TTY; false otherwise.
|
|
* A return of true does not guarantee the terminal accepted the write —
|
|
* some terminals disable OSC 52 by default.
|
|
*/
|
|
function copyToClipboardViaOsc52(text: string): boolean {
|
|
const base64 = Buffer.from(text, 'utf8').toString('base64');
|
|
const seq = wrapForMultiplexer(`\x1b]52;c;${base64}\x07`);
|
|
const stream = process.stderr.isTTY
|
|
? process.stderr
|
|
: process.stdout.isTTY
|
|
? process.stdout
|
|
: null;
|
|
if (!stream) return false;
|
|
try {
|
|
stream.write(seq);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|
server,
|
|
onBack,
|
|
}) => {
|
|
const config = useConfig();
|
|
const [authState, setAuthState] = useState<AuthState>('idle');
|
|
const [messages, setMessages] = useState<string[]>([]);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
|
const [copyState, setCopyState] = useState<
|
|
{ status: 'idle' } | { status: 'copied' | 'unsupported'; nonce: number }
|
|
>({ status: 'idle' });
|
|
const isRunning = useRef(false);
|
|
|
|
const runAuthentication = useCallback(async () => {
|
|
if (!server || !config || isRunning.current) return;
|
|
isRunning.current = true;
|
|
|
|
setAuthState('authenticating');
|
|
setMessages([]);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
setMessages([
|
|
t("Starting OAuth authentication for MCP server '{{name}}'...", {
|
|
name: server.name,
|
|
}),
|
|
]);
|
|
|
|
let oauthConfig = server.config.oauth;
|
|
if (!oauthConfig) {
|
|
oauthConfig = { enabled: false };
|
|
}
|
|
|
|
const mcpServerUrl = server.config.httpUrl || server.config.url;
|
|
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
|
|
await authProvider.authenticate(
|
|
server.name,
|
|
oauthConfig,
|
|
mcpServerUrl,
|
|
appEvents,
|
|
);
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
t("Successfully authenticated and refreshed tools for '{{name}}'.", {
|
|
name: server.name,
|
|
}),
|
|
]);
|
|
|
|
// Trigger tool re-discovery to pick up authenticated server
|
|
const toolRegistry = config.getToolRegistry();
|
|
if (toolRegistry) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
t("Re-discovering tools from '{{name}}'...", {
|
|
name: server.name,
|
|
}),
|
|
]);
|
|
await toolRegistry.discoverToolsForServer(server.name);
|
|
|
|
// Show discovered tool count
|
|
const discoveredTools = toolRegistry.getToolsByServer(server.name);
|
|
setMessages((prev) => [
|
|
...prev,
|
|
t("Discovered {{count}} tool(s) from '{{name}}'.", {
|
|
count: String(discoveredTools.length),
|
|
name: server.name,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
// Update the client with the new tools
|
|
const geminiClient = config.getGeminiClient();
|
|
if (geminiClient) {
|
|
await geminiClient.setTools();
|
|
}
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
t('Authentication complete. Returning to server details...'),
|
|
]);
|
|
|
|
setAuthState('success');
|
|
} catch (error) {
|
|
setErrorMessage(getErrorMessage(error));
|
|
setAuthState('error');
|
|
} finally {
|
|
isRunning.current = false;
|
|
}
|
|
}, [server, config]);
|
|
|
|
// Subscribe to OAuth events for the lifetime of this component. Keeping
|
|
// the subscription tied to mount/unmount (rather than to runAuthentication's
|
|
// async flow) ensures listeners are released immediately on unmount even if
|
|
// the authentication promise is still pending.
|
|
useEffect(() => {
|
|
const displayListener = (message: OAuthDisplayPayload) => {
|
|
const text =
|
|
typeof message === 'string' ? message : t(message.key, message.params);
|
|
setMessages((prev) => [...prev, text]);
|
|
};
|
|
const authUrlListener = (url: string) => {
|
|
setAuthUrl(url);
|
|
};
|
|
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
|
appEvents.on(AppEvent.OauthAuthUrl, authUrlListener);
|
|
return () => {
|
|
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
|
appEvents.removeListener(AppEvent.OauthAuthUrl, authUrlListener);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
runAuthentication();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Auto-navigate back after authentication succeeds
|
|
useEffect(() => {
|
|
if (authState !== 'success') return;
|
|
const timer = setTimeout(() => {
|
|
onBack();
|
|
}, AUTO_BACK_DELAY_MS);
|
|
return () => clearTimeout(timer);
|
|
}, [authState, onBack]);
|
|
|
|
useKeypress(
|
|
(key) => {
|
|
if (key.name === 'escape') {
|
|
onBack();
|
|
return;
|
|
}
|
|
if (
|
|
key.name === 'c' &&
|
|
!key.ctrl &&
|
|
!key.meta &&
|
|
!key.paste &&
|
|
authUrl &&
|
|
authState === 'authenticating'
|
|
) {
|
|
const ok = copyToClipboardViaOsc52(authUrl);
|
|
setCopyState({
|
|
status: ok ? 'copied' : 'unsupported',
|
|
nonce: Date.now(),
|
|
});
|
|
}
|
|
},
|
|
{ isActive: true },
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (copyState.status === 'idle') return;
|
|
const timer = setTimeout(
|
|
() => setCopyState({ status: 'idle' }),
|
|
COPY_FEEDBACK_MS,
|
|
);
|
|
return () => clearTimeout(timer);
|
|
// Depend on the nonce so repeated presses reset the timer.
|
|
}, [copyState]);
|
|
|
|
if (!server) {
|
|
return (
|
|
<Box>
|
|
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column" gap={1}>
|
|
{/* Server info */}
|
|
<Box>
|
|
<Text color={theme.text.secondary}>
|
|
{t('Server:')} {server.name}
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Progress messages */}
|
|
{messages.length > 0 && (
|
|
<Box flexDirection="column">
|
|
{messages.map((msg, i) => (
|
|
<Text key={i} color={theme.text.secondary}>
|
|
{msg}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{authState === 'error' && errorMessage && (
|
|
<Box>
|
|
<Text color={theme.status.error}>{errorMessage}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{authUrl && (
|
|
<Box>
|
|
<Text color={theme.text.accent}>{osc8Hyperlink(authUrl)}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Action hints */}
|
|
<Box flexDirection="column">
|
|
{authState === 'authenticating' && (
|
|
<Text color={theme.text.secondary}>
|
|
{t('Authenticating... Please complete the login in your browser.')}
|
|
</Text>
|
|
)}
|
|
{authState === 'authenticating' && authUrl && (
|
|
<Text
|
|
bold={copyState.status === 'idle'}
|
|
color={
|
|
copyState.status === 'copied'
|
|
? theme.status.success
|
|
: copyState.status === 'unsupported'
|
|
? theme.status.warning
|
|
: theme.text.accent
|
|
}
|
|
>
|
|
{copyState.status === 'copied'
|
|
? t(
|
|
'Copy request sent to your terminal. If paste is empty, copy the URL above manually.',
|
|
)
|
|
: copyState.status === 'unsupported'
|
|
? t('Cannot write to terminal — copy the URL above manually.')
|
|
: t('Press c to copy the authorization URL to your clipboard.')}
|
|
</Text>
|
|
)}
|
|
{authState === 'success' && (
|
|
<Text color={theme.status.success}>
|
|
{t('Authentication successful.')}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|