From ed8b3bcc194c8263ec8bec3f337053ae73866da1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 1 Oct 2026 15:12:24 +0200 Subject: [PATCH] feat(coding-agent): offer Radius sign-in and MCP setup in /login The top-level /login menu offers Sign in with Radius with its status, with an animated shimmer on the Radius row and a short Radius intro. After a Radius sign-in it offers to configure the Radius MCP server in the global mcp.json with auth.provider and reloads. Cancelling a login returns to the menu it was started from. OAuth sign-ins without a subscription are labeled as accounts. Co-authored-by: niloc <5925270+colindaymond@users.noreply.github.com> --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/core/radius.ts | 2 + .../interactive/components/oauth-selector.ts | 56 +++--- .../components/radius-login-selector.ts | 114 +++++++++++ .../src/modes/interactive/interactive-mode.ts | 188 ++++++++++++++---- .../test/interactive-mode-status.test.ts | 12 +- 6 files changed, 306 insertions(+), 68 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4685b0a25..ff62460da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed +- `/login` now offers "Sign in with Radius" at the top level, as the last option, with its status. After a Radius sign-in, `/login` offers to configure the Radius MCP server in the global `mcp.json` with `"auth": { "provider": "radius" }` and reloads. Cancelling a login returns to the menu it was started from. - MCP OAuth credentials are now stored per server name and URL, so MCP servers with the same URL can sign in with different accounts. Credentials stored by URL alone move to the first server that uses them ([#10252](https://github.com/earendil-works/pi/issues/10252)). ### Fixed @@ -19,6 +20,7 @@ - Fixed `--provider` without `--model` being silently ignored and running the default model from another provider; it now fails with an error ([#10236](https://github.com/earendil-works/pi/issues/10236)). - Fixed MCP servers that ask for more scope (`insufficient_scope`) requesting sign-in over and over. The new sign-in requested only the missing scopes, so the new token lost access the previous one had; it now keeps the granted scopes. - Fixed user messages in the transcript keeping two full-width copies of every rendered line; they keep one, with identical output. +- Fixed `/login` and `/logout` labeling every OAuth sign-in, including Radius, as a subscription; only subscription-backed providers say "subscription", other OAuth sign-ins say "account". - Fixed deferred MCP tools that `tool_search` loaded being dropped on resume and `/reload` even when their server reconnected before the next prompt, because the session restored its tools before the MCP servers reconnected. ## [0.99.2] - 2026-09-30 diff --git a/packages/coding-agent/src/core/radius.ts b/packages/coding-agent/src/core/radius.ts index 833c59be0..c37b7a5a4 100644 --- a/packages/coding-agent/src/core/radius.ts +++ b/packages/coding-agent/src/core/radius.ts @@ -2,6 +2,8 @@ import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-wor export const RADIUS_PROVIDER_ID = "radius"; export const ENV_RADIUS_GATEWAY = "PI_RADIUS_GATEWAY"; +/** MCP endpoint of the gateway the built-in Radius provider signs in to. */ +export const RADIUS_MCP_URL = `${normalizeRadiusGatewayUrl(DEFAULT_RADIUS_GATEWAY)}/mcp`; /** Radius gateway origin, honoring the `PI_RADIUS_GATEWAY` override. */ export function getRadiusGatewayUrl(): string { diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 47020ea9d..c03f74836 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -17,10 +17,39 @@ export type AuthSelectorProvider = { authType: "oauth" | "api_key"; method?: ApiKeyAuth | OAuthAuth; status?: AuthCheck; + /** + * Whether the provider's OAuth sign-in is backed by a subscription. `false` labels it as an account; + * unset keeps the "subscription" label. + */ + subscription?: boolean; }; -export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string { - return authType === "oauth" ? "subscription" : "API key"; +export function formatAuthSelectorProviderType( + authType: AuthSelectorProvider["authType"], + subscription?: boolean, +): string { + if (authType === "api_key") return "API key"; + return subscription === false ? "account" : "subscription"; +} + +/** Themed suffix describing whether and how a login option is configured, for example " ✓ configured". */ +export function formatAuthSelectorProviderStatus(provider: AuthSelectorProvider): string { + if (!provider.status) return theme.fg("muted", " • not configured"); + if (provider.status.type !== provider.authType) { + const label = `${formatAuthSelectorProviderType(provider.status.type, provider.subscription)} configured`; + return theme.fg("muted", " • ") + theme.fg("warning", label); + } + if ( + !provider.status.source || + provider.status.source === "OAuth" || + provider.status.source === "stored credential" + ) { + return theme.fg("success", " ✓ configured"); + } + const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source) + ? `env: ${provider.status.source}` + : provider.status.source; + return theme.fg("success", ` ✓ ${source}`); } /** @@ -127,9 +156,9 @@ export class OAuthSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; - const statusIndicator = this.formatStatusIndicator(provider); + const statusIndicator = formatAuthSelectorProviderStatus(provider); const authTypeLabel = this.showAuthTypeLabels - ? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType)}]`) + ? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType, provider.subscription)}]`) : ""; let line = ""; if (isSelected) { @@ -161,25 +190,6 @@ export class OAuthSelectorComponent extends Container implements Focusable { } } - private formatStatusIndicator(provider: AuthSelectorProvider): string { - if (!provider.status) return theme.fg("muted", " • not configured"); - if (provider.status.type !== provider.authType) { - const label = provider.status.type === "oauth" ? "subscription configured" : "API key configured"; - return theme.fg("muted", " • ") + theme.fg("warning", label); - } - if ( - !provider.status.source || - provider.status.source === "OAuth" || - provider.status.source === "stored credential" - ) { - return theme.fg("success", " ✓ configured"); - } - const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source) - ? `env: ${provider.status.source}` - : provider.status.source; - return theme.fg("success", ` ✓ ${source}`); - } - handleInput(keyData: string): void { const kb = getKeybindings(); // Up arrow diff --git a/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts b/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts new file mode 100644 index 000000000..c6a7bc3c6 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts @@ -0,0 +1,114 @@ +/** + * The `/login` menu with the animated "Sign in with Radius" option. Internal to the interactive mode: the shimmer + * is Radius-only and is not exposed to other selectors. + */ + +import { type Color, foregroundAnsi, mixColors, parseColor, Text, type TUI } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; +import { ExtensionSelectorComponent } from "./extension-selector.ts"; + +/** The four colors of the Radius logo, in the order they stream across the text. */ +const RADIUS_COLORS: readonly Color[] = ["#4d9abf", "#83ccd2", "#f1be57", "#f09082"].map((hex) => parseColor(hex)); +/** Width of each color band, in characters. */ +const CHARS_PER_COLOR = 4; +const CHARS_PER_SECOND = 10; +const ANIMATION_FRAME_MS = 50; + +/** Color `text` with the Radius logo colors flowing left to right; `elapsedMs` is the animation time. */ +function radiusShimmer(text: string, elapsedMs: number): string { + const mode = theme.getColorMode(); + const cycle = RADIUS_COLORS.length * CHARS_PER_COLOR; + const offset = (elapsedMs / 1000) * CHARS_PER_SECOND; + let result = ""; + let index = 0; + for (const char of text) { + const position = (((index - offset) % cycle) + cycle) % cycle; + const band = Math.floor(position / CHARS_PER_COLOR); + const t = position / CHARS_PER_COLOR - band; + // Smoothstep keeps each band recognizable while still blending into the next one. + const amount = t * t * (3 - 2 * t); + const from = RADIUS_COLORS[band] as Color; + const to = RADIUS_COLORS[(band + 1) % RADIUS_COLORS.length] as Color; + result += foregroundAnsi(mixColors(from, to, amount, "srgb"), mode) + char; + index++; + } + return `${result}\x1b[39m`; +} + +/** The "Sign in with Radius" option: `label` is the full option, starting with the animated `text`. */ +type RadiusOption = { label: string; text: string }; + +/** + * Swaps the line `ExtensionSelectorComponent` draws for the selected Radius option with the animated one. When the + * selector's row style changes or the label wraps, the line no longer matches and the option renders normally. + */ +class RadiusLoginMenuComponent extends ExtensionSelectorComponent { + private readonly radiusOption: RadiusOption; + private readonly animationStart = performance.now(); + private animationTimer: ReturnType | undefined; + private animating = false; + + constructor( + tui: TUI, + title: string, + options: string[], + radiusOption: RadiusOption, + onSelect: (option: string) => void, + onCancel: () => void, + ) { + super( + title, + options, + (option) => { + this.stopAnimation(); + onSelect(option); + }, + () => { + this.stopAnimation(); + onCancel(); + }, + ); + this.radiusOption = radiusOption; + this.animationTimer = setInterval(() => { + if (this.animating) tui.requestRender(); + }, ANIMATION_FRAME_MS); + this.animationTimer.unref?.(); + } + + override render(width: number): string[] { + const lines = super.render(width); + const { label, text } = this.radiusOption; + const selectedLine = new Text(theme.fg("accent", "→ ") + theme.fg("accent", label), 1, 0).render(width)[0]; + const index = selectedLine === undefined ? -1 : lines.indexOf(selectedLine); + this.animating = index >= 0; + if (this.animating) { + const shimmer = radiusShimmer(text, performance.now() - this.animationStart); + const animatedLine = theme.fg("accent", "→ ") + shimmer + label.slice(text.length); + lines[index] = new Text(animatedLine, 1, 0).render(width)[0] ?? ""; + } + return lines; + } + + private stopAnimation(): void { + clearInterval(this.animationTimer); + this.animationTimer = undefined; + this.animating = false; + } + + override dispose(): void { + this.stopAnimation(); + super.dispose(); + } +} + +/** Top-level `/login` selector whose Radius option shimmers in the Radius logo colors while it is selected. */ +export function createLoginMenuSelector( + tui: TUI, + title: string, + options: string[], + radiusOption: RadiusOption, + onSelect: (option: string) => void, + onCancel: () => void, +): ExtensionSelectorComponent { + return new RadiusLoginMenuComponent(tui, title, options, radiusOption, onSelect, onCancel); +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 415098dbb..cd08f6237 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -94,6 +94,7 @@ import type { import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; +import type { McpHttpServerConfig } from "../../core/mcp-servers.ts"; import { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts"; import { defaultModelPerProvider, @@ -102,6 +103,7 @@ import { } from "../../core/model-resolver.ts"; import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; +import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { @@ -118,6 +120,7 @@ import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; +import { addMcpServerConfig, loadMcpConfig } from "../../extensions/mcp/config.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -151,11 +154,13 @@ import { createMermaidMarkdownTransformer } from "./components/mermaid.ts"; import { ModelSelectorComponent } from "./components/model-selector.ts"; import { type AuthSelectorProvider, + formatAuthSelectorProviderStatus, formatAuthSelectorProviderType, OAuthSelectorComponent, } from "./components/oauth-selector.ts"; import { piLogoLines } from "./components/pi-logo.ts"; import { playPiLogoAnimation } from "./components/pi-logo-animation.lazy.ts"; +import { createLoginMenuSelector } from "./components/radius-login-selector.ts"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; import { SessionSelectorComponent } from "./components/session-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts"; @@ -356,8 +361,11 @@ type LoginProviderCompletionOption = { id: string; name: string; authTypes: AuthSelectorProvider["authType"][]; + subscription?: boolean; }; +const RADIUS_LOGIN_INTRO = "Radius is a service crafted for Pi by the builders of Pi, Earendil Works"; + const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record; function createFuzzyAutocompleteItems( @@ -388,6 +396,7 @@ function getLoginProviderCompletionOptions( id: provider.id, name: provider.name, authTypes: [provider.authType], + subscription: provider.subscription, }); } return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); @@ -395,13 +404,15 @@ function getLoginProviderCompletionOptions( function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string { const authTypes = provider.authTypes - .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`) + .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType, provider.subscription)}`) .join(" "); return `${provider.id} ${provider.name} ${authTypes}`; } function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string { - const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/"); + const authTypes = provider.authTypes + .map((authType) => formatAuthSelectorProviderType(authType, provider.subscription)) + .join("/"); return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`; } @@ -5705,6 +5716,7 @@ export class InteractiveMode { source: authStatus.label ?? authStatus.source, } : undefined; + const subscription = provider.auth.oauth?.isSubscription === true; if ((!authType || authType === "oauth") && provider.auth.oauth) { options.push({ id: provider.id, @@ -5712,6 +5724,7 @@ export class InteractiveMode { authType: "oauth", method: provider.auth.oauth, status, + subscription, }); } if ((!authType || authType === "api_key") && provider.auth.apiKey) { @@ -5721,6 +5734,7 @@ export class InteractiveMode { authType: "api_key", method: provider.auth.apiKey, status, + subscription, }); } } @@ -5729,12 +5743,16 @@ export class InteractiveMode { private async getLogoutProviderOptions(): Promise { return (await this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) })) - .map(({ providerId, type }) => ({ - id: providerId, - name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId, - authType: type, - status: { type, source: "stored credential" }, - })) + .map(({ providerId, type }) => { + const provider = this.session.modelRuntime.getProvider(providerId); + return { + id: providerId, + name: provider?.name ?? providerId, + authType: type, + status: { type, source: "stored credential" }, + subscription: provider?.auth.oauth?.isSubscription === true, + }; + }) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -5774,17 +5792,24 @@ export class InteractiveMode { this.showLoginProviderSelector(undefined, providerRef); } - private async startProviderLogin(providerOption: AuthSelectorProvider): Promise { + /** `onBack` reopens the selector the login was started from when the user cancels it. */ + private async startProviderLogin(providerOption: AuthSelectorProvider, onBack?: () => void): Promise { if (providerOption.authType === "oauth") { - await this.showLoginDialog(providerOption.id, providerOption.name); + await this.showLoginDialog(providerOption.id, providerOption.name, onBack); } else if (providerOption.method?.login) { - await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); + await this.showApiKeyLoginDialog(providerOption.id, providerOption.name, onBack); } else { - this.showAmbientAuthDialog(providerOption); + this.showAmbientAuthDialog(providerOption, onBack); } } private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void { + // The top-level selector offers Radius directly, as its last option. + const radiusOption = providerOptions + ? undefined + : this.getLoginProviderOptions("oauth").find((provider) => provider.id === RADIUS_PROVIDER_ID); + const radiusText = radiusOption ? `Sign in with ${radiusOption.name}` : undefined; + const radiusLabel = radiusOption ? `${radiusText}${formatAuthSelectorProviderStatus(radiusOption)}` : undefined; const oauthProvider = providerOptions?.find((provider) => provider.authType === "oauth"); const oauthLoginLabel = oauthProvider?.method && "loginLabel" in oauthProvider.method ? oauthProvider.method.loginLabel : undefined; @@ -5800,6 +5825,7 @@ export class InteractiveMode { if (availableAuthTypes.has("api_key")) { options.push(apiKeyLabel); } + if (radiusLabel) options.push(radiusLabel); if (options.length === 0) { this.showStatus("No login methods available."); @@ -5818,27 +5844,38 @@ export class InteractiveMode { ? `Select authentication method for ${providerOptions[0].name}:` : "Select authentication method:"; this.showSelector((done) => { - const selector = new ExtensionSelectorComponent( - title, - options, - (option) => { - done(); - const authType = option === subscriptionLabel ? "oauth" : "api_key"; - if (providerOptions) { - const providerOption = providerOptions.find((provider) => provider.authType === authType); - if (providerOption) { - void this.startProviderLogin(providerOption); - } - return; + const onSelect = (option: string) => { + done(); + if (radiusOption && option === radiusLabel) { + void this.startProviderLogin(radiusOption, () => this.showLoginAuthTypeSelector()); + return; + } + const authType = option === subscriptionLabel ? "oauth" : "api_key"; + if (providerOptions) { + const providerOption = providerOptions.find((provider) => provider.authType === authType); + if (providerOption) { + void this.startProviderLogin(providerOption, () => this.showLoginAuthTypeSelector(providerOptions)); } - this.showLoginProviderSelector(authType); - }, - () => { - done(); - this.ui.requestRender(); - }, - ); - return { component: selector, focus: selector }; + return; + } + this.showLoginProviderSelector(authType); + }; + const onCancel = () => { + done(); + this.ui.requestRender(); + }; + const selector = + radiusLabel && radiusText + ? createLoginMenuSelector( + this.ui, + title, + options, + { label: radiusLabel, text: radiusText }, + onSelect, + onCancel, + ) + : new ExtensionSelectorComponent(title, options, onSelect, onCancel); + return { component: selector, focus: selector, dispose: () => selector.dispose() }; }); } @@ -5847,7 +5884,7 @@ export class InteractiveMode { if (providerOptions.length === 0) { const message = authType === "oauth" - ? "No subscription providers available." + ? "No account providers available." : authType === "api_key" ? "No API key providers available." : "No login providers available."; @@ -5869,7 +5906,9 @@ export class InteractiveMode { return; } - await this.startProviderLogin(providerOption); + await this.startProviderLogin(providerOption, () => + this.showLoginProviderSelector(authType, initialSearchInput), + ); }, () => { done(); @@ -6042,7 +6081,7 @@ export class InteractiveMode { .finally(() => clearTimeout(timeout)); } - private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void { + private showAmbientAuthDialog(providerOption: AuthSelectorProvider, onBack?: () => void): void { const restoreEditor = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); @@ -6053,7 +6092,10 @@ export class InteractiveMode { const dialog = new LoginDialogComponent( this.ui, providerOption.id, - () => restoreEditor(), + () => { + restoreEditor(); + onBack?.(); + }, providerOption.name, `${providerOption.name} setup`, ); @@ -6069,7 +6111,7 @@ export class InteractiveMode { this.ui.requestRender(); } - private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise { + private async showApiKeyLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise { const previousModel = this.session.model; const dialog = new LoginDialogComponent( @@ -6112,7 +6154,9 @@ export class InteractiveMode { this.showError( `Saved API key for ${providerName}, but local model state could not be synchronized: ${errorMsg}`, ); - } else if (errorMsg !== "Login cancelled") { + } else if (errorMsg === "Login cancelled") { + onBack?.(); + } else { this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`); } } @@ -6121,6 +6165,7 @@ export class InteractiveMode { private showAuthSelect( dialog: LoginDialogComponent, prompt: Extract, + providerId: string, ): Promise { return new Promise((resolve, reject) => { const restoreDialog = () => { @@ -6143,6 +6188,7 @@ export class InteractiveMode { restoreDialog(); reject(new Error("Login cancelled")); }, + { description: providerId === RADIUS_PROVIDER_ID ? RADIUS_LOGIN_INTRO : undefined }, ); this.editorContainer.clear(); this.editorContainer.addChild(selector); @@ -6151,10 +6197,10 @@ export class InteractiveMode { }); } - private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise { + private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt, providerId: string): Promise { let response: Promise; if (prompt.type === "select") { - response = this.showAuthSelect(dialog, prompt); + response = this.showAuthSelect(dialog, prompt, providerId); } else if (prompt.type === "manual_code") { response = dialog.showManualInput(prompt.message); } else { @@ -6198,14 +6244,14 @@ export class InteractiveMode { method, { signal: dialog.signal, - prompt: (prompt) => this.showAuthPrompt(dialog, prompt), + prompt: (prompt) => this.showAuthPrompt(dialog, prompt, providerId), notify: (event) => this.notifyAuthDialog(dialog, event), }, { getDeviceId: () => this.settingsManager.getOrCreateDeviceId() }, ); } - private async showLoginDialog(providerId: string, providerName: string): Promise { + private async showLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise { const previousModel = this.session.model; const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName); this.editorContainer.clear(); @@ -6224,6 +6270,7 @@ export class InteractiveMode { await this.loginProvider(dialog, providerId, "oauth"); restoreEditor(); await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); + if (providerId === RADIUS_PROVIDER_ID) this.offerRadiusMcpServer(providerId, providerName); } catch (error: unknown) { restoreEditor(); const errorMsg = error instanceof Error ? error.message : String(error); @@ -6231,12 +6278,67 @@ export class InteractiveMode { this.showError( `Logged in to ${providerName}, but local model state could not be synchronized: ${errorMsg}`, ); - } else if (errorMsg !== "Login cancelled") { + } else if (errorMsg === "Login cancelled") { + onBack?.(); + } else { this.showError(`Failed to login to ${providerName}: ${errorMsg}`); } } } + /** + * Offer to point the Radius MCP server in the global mcp.json at the Radius login, adding the server + * when missing. Nothing is asked when a global server already uses this login. + */ + private offerRadiusMcpServer(providerId: string, providerName: string): void { + const mcpPath = path.join(getAgentDir(), "mcp.json"); + const normalizeUrl = (url: string) => url.replace(/\/+$/u, ""); + const { servers } = loadMcpConfig({ + agentDir: getAgentDir(), + cwd: this.sessionManager.getCwd(), + projectTrusted: false, + }); + const existing = servers.find( + (server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL), + ); + if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return; + + let name = existing?.name ?? "radius"; + if (!existing && servers.some((server) => server.name === name)) name = "radius-mcp"; + const config: McpHttpServerConfig = + existing && "url" in existing.config + ? { ...existing.config, auth: { provider: providerId } } + : { url: RADIUS_MCP_URL, auth: { provider: providerId } }; + // `auth` replaces the MCP OAuth sign-in. + delete config.oauth; + + this.showSelector((done) => { + const selector = new ExtensionSelectorComponent( + `Configure ${providerName} MCP in ${mcpPath}?`, + ["Yes", "No"], + (option) => { + done(); + if (option !== "Yes") return; + try { + addMcpServerConfig(mcpPath, name, config); + } catch (error: unknown) { + this.showError( + `Could not update ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + // The MCP extension reads mcp.json when the session starts. + void this.handleReloadCommand(); + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector }; + }); + } + // ========================================================================= // Command handlers // ========================================================================= diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 797c51165..103b23d33 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -487,9 +487,10 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { sessionManager: { getCwd: () => "/tmp" }, fdPath: null, getLoginProviderOptions: () => [ - { id: "anthropic", name: "Anthropic", authType: "oauth" }, - { id: "anthropic", name: "Anthropic", authType: "api_key" }, + { id: "anthropic", name: "Anthropic", authType: "oauth", subscription: true }, + { id: "anthropic", name: "Anthropic", authType: "api_key", subscription: true }, { id: "openai", name: "OpenAI", authType: "api_key" }, + { id: "radius", name: "Radius", authType: "oauth", subscription: false }, ], }; @@ -506,6 +507,13 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { description: "Anthropic · subscription/API key", }, ]); + + // OAuth sign-in without a subscription, such as Radius, is an account. + const radiusLine = "/login radius"; + const radiusSuggestions = await provider.getSuggestions([radiusLine], 0, radiusLine.length, { + signal: new AbortController().signal, + }); + expect(radiusSuggestions?.items).toEqual([{ value: "radius", label: "radius", description: "Radius · account" }]); }); }); describe("InteractiveMode.showLoadedResources", () => {