fix(mcp): make the OAuth authorization URL clickable when wrapped#3489
Conversation
…hen 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.
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.
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.
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.
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).
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.
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.
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.
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".
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
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.
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.
|
Manual E2E Test Cases Passed: 4.21.mp4 |
@BZ-D Why not put the URL inside the dialog? I think that would be a better UI.
|
tmux DCS passthrough — manual verificationRan a PTY-based harness (tmux 3.6a on macOS, Results
This confirms:
Gotcha worth noting for anyone reproducing this: in tmux 3.6a |
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.
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).
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.
@LaZzyMan Good call — I moved it back inside the dialog in The earlier
|
) * 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.
…enLM#3489) * fix(mcp): render OAuth URL as OSC 8 hyperlink so it stays clickable when wrapped Closes QwenLM#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.


Closes #3470.
Problem
When
qwen-codeopened an MCP OAuth flow, the authorization URL was rendered inside the dialog box and hard-wrapped by Ink. Most terminals only treated the first visible line as a clickable link — the remaining wrapped lines lost the hyperlink target, and selecting with the mouse pulled in stray newlines and a broken URL. Thec-to-copy fallback helped, but direct click is the natural interaction on modern terminals.Fix
Render the authorization URL inside the dialog as a single OSC 8 hyperlink
Text. Theosc8Hyperlinkhelper wraps the URL in\x1b]8;;URL\x07URL\x1b]8;;\x07and — when running inside a terminal multiplexer — in a DCS passthrough envelope so tmux/screen forward the escape to the host terminal instead of eating it.\x07rather than ST (ESC \) — broader terminal compatibility.wrapForMultiplexeremits\x1bPtmux;…\x1b\\under$TMUXand\x1bP…\x1b\\under$STY(GNU screen). Shared with the OSC 52 clipboard path.oauth-provider.ts, stop duplicating the raw URL throughdisplayMessagewhen an event emitter is present (the UI renders it fromOAUTH_AUTH_URL_EVENT); keep the plain-text fallback for non-UI callers. Rename the in-dialog prompt key from…into your browser:to…into your browser.(URL no longer follows on the next line) and restore the previously-droppedMake sure to copy the COMPLETE URL — it may wrap across multiple lines.warning. Translations updated inde/en/fr/ja/pt/ru/zh.The
c-to-copy / OSC 52 affordance is unchanged and remains the fallback for terminals without OSC 8 support. Because the URL lives inside the dialog's<Box>, it disappears together with the dialog on auth success /Esc/ dismiss — no extra scrollback bookkeeping needed.Known limitations
allow-passthroughtooff. Users on default configs will not see the DCS-wrapped OSC 8 until they setset -g allow-passthrough on. Noted in the code comment.Test plan
npx vitest run packages/core/src/mcp/oauth-provider.test.ts(22 tests pass)npx vitest run packages/cli/src/ui/components/mcp(16 tests pass)Esc, and after dismissing the dialog.@lydell/node-pty): the inner OSC 8 (\x1b]8;;URL\x07…\x1b]8;;\x07) appears in tmux's PTY output whenallow-passthrough=onand is absent whenallow-passthrough=off. Confirms the\x1bPtmux;…\x1b\\wrapper reaches the host terminal unmodified.c-to-copy fallback still works.