feat(coding-agent): copy OAuth sign-in URLs with app.message.copy

Login and MCP sign-in screens show a copy hint and copy the URL to the
clipboard (OSC 52 over SSH). /mcp login in the TUI now uses the MCP
sign-in screen instead of a notification.
This commit is contained in:
Armin Ronacher 2026-10-02 10:10:25 +02:00
parent c450f2c0f8
commit ced72c2f09
7 changed files with 162 additions and 45 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added a copy key (`app.message.copy`, default `ctrl+x`) to OAuth sign-in screens in `/login`, `/mcp`, and `/mcp login`, which copies the sign-in URL when the browser cannot be opened or the wrapped link cannot be selected.
## [1.0.0] - 2026-10-01
### New Features

View file

@ -161,7 +161,7 @@ On native Windows, `app.suspend` has no default because Windows terminals do not
| Keybinding id | Default | Description |
|--------|---------|-------------|
| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
| `app.message.copy` | `ctrl+x` | Copy the selected message in `/tree`; in fullscreen mode, copy the active selection when `fullscreenCopyOnSelect` is `false`; otherwise copy the last assistant message |
| `app.message.copy` | `ctrl+x` | Copy the selected message in `/tree`; in fullscreen mode, copy the active selection when `fullscreenCopyOnSelect` is `false`; otherwise copy the last assistant message. On OAuth sign-in screens, copy the sign-in URL |
| `app.message.followUp` | `alt+enter` (`ctrl+q` on Windows and WSL) | Queue follow-up message |
| `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor |

View file

@ -27,7 +27,7 @@
*/
import { join, resolve } from "node:path";
import { hyperlink, type SelectItem } from "@earendil-works/pi-tui";
import type { SelectItem } from "@earendil-works/pi-tui";
import type { TSchema } from "typebox";
import { getAgentDir } from "../../config.ts";
import type {
@ -797,27 +797,31 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
return setExposure(server, choice as McpExposure);
};
/** Sign in with the manager view's sign-in screen, which shows the URL with a copy key. */
const signInWithUi = (ui: McpUi, server: McpServer): Promise<string | undefined> => {
const title = `Sign in to ${server.entry.name}`;
let authorizationUrl = "";
ui.status(title, "Contacting the authorization server…");
return signIn(server, {
showAuthorizationUrl: (url) => {
authorizationUrl = url.href;
openUrl(url.href);
},
promptForRedirectUrl: async (signal) => {
const value = await ui.redirectUrl(title, authorizationUrl, signal);
ui.status(title, "Connecting…");
return value;
},
});
};
const runAction = async (ui: McpUi, ctx: ExtensionContext, server: McpServer, action: string) => {
const { name } = server.entry;
let message: string | undefined;
switch (action) {
case "signin": {
const title = `Sign in to ${name}`;
let authorizationUrl = "";
ui.status(title, "Contacting the authorization server…");
message = await signIn(server, {
showAuthorizationUrl: (url) => {
authorizationUrl = url.href;
openUrl(url.href);
},
promptForRedirectUrl: async (signal) => {
const value = await ui.redirectUrl(title, authorizationUrl, signal);
ui.status(title, "Connecting…");
return value;
},
});
case "signin":
message = await signInWithUi(ui, server);
break;
}
case "reconnect":
// A failure shows as the connection's state and error.
ui.status(`MCP server ${name}`, "Reconnecting…");
@ -926,23 +930,25 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
ctx.ui.notify(`Signing in to MCP server "${name}" requires interactive mode.`, "error");
return;
}
const failure = await signIn(server, {
showAuthorizationUrl: (url) => {
// Long URLs wrap, which some terminals cannot open; a short link line stays on one line.
const lines =
ctx.mode === "tui"
? `${hyperlink(url.href, url.href)}\n${hyperlink(process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open", url.href)}`
: url.href;
ctx.ui.notify(`Sign in to MCP server "${name}" in your browser:\n${lines}`, "info");
openUrl(url.href);
},
promptForRedirectUrl: (signal) =>
ctx.ui.input(
`Waiting for sign-in to "${name}". If the browser cannot reach this machine, paste the URL it was redirected to.`,
"http://127.0.0.1:.../callback?code=...",
{ signal },
),
});
let failure: string | undefined;
if (ctx.mode === "tui") {
await showMcpManager(ctx, async (ui) => {
failure = await signInWithUi(ui, server);
});
} else {
failure = await signIn(server, {
showAuthorizationUrl: (url) => {
ctx.ui.notify(`Sign in to MCP server "${name}" in your browser:\n${url.href}`, "info");
openUrl(url.href);
},
promptForRedirectUrl: (signal) =>
ctx.ui.input(
`Waiting for sign-in to "${name}". If the browser cannot reach this machine, paste the URL it was redirected to.`,
"http://127.0.0.1:.../callback?code=...",
{ signal },
),
});
}
if (failure) {
ctx.ui.notify(failure, failure === "Sign-in cancelled." ? "info" : "error");
return;

View file

@ -7,7 +7,6 @@ import {
type Component,
Container,
type Focusable,
hyperlink,
Input,
type SelectItem,
SelectList,
@ -19,6 +18,7 @@ import {
} from "@earendil-works/pi-tui";
import type { ExtensionCommandContext } from "../../core/extensions/types.ts";
import type { KeybindingsManager } from "../../core/keybindings.ts";
import { AuthUrlComponent } from "../../modes/interactive/components/auth-url.ts";
import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.ts";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { getSelectListTheme, type Theme } from "../../modes/interactive/theme/theme.ts";
@ -176,12 +176,11 @@ export class McpManagerView implements McpUi, Component, Focusable {
}
signal.addEventListener("abort", onAbort, { once: true });
const input = new Input();
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const link = new AuthUrlComponent(this.tui, authorizationUrl);
const body: Component[] = [
new Spacer(1),
new Text(this.theme.fg("muted", "Approve access in your browser. If it did not open, visit:"), 1, 0),
new Text(this.theme.fg("accent", hyperlink(authorizationUrl, authorizationUrl)), 1, 0),
new Text(this.theme.fg("dim", hyperlink(clickHint, authorizationUrl)), 1, 0),
link,
new Spacer(1),
new Text(
this.theme.fg("muted", "If the browser runs on another machine, paste the URL it was redirected to:"),
@ -207,6 +206,10 @@ export class McpManagerView implements McpUi, Component, Focusable {
finish(undefined);
return;
}
if (this.keybindings.matches(data, "app.message.copy")) {
void link.copy();
return;
}
input.handleInput(data);
},
input,

View file

@ -0,0 +1,39 @@
import { Container, hyperlink, Text, type TUI } from "@earendil-works/pi-tui";
import { copyToClipboard } from "../../../utils/clipboard.ts";
import { theme } from "../theme/theme.ts";
import { keyHint } from "./keybinding-hints.ts";
/**
* A sign-in URL with a click hint and a copy hint. Hosts call `copy()` when `app.message.copy` is
* pressed, since a long URL wraps and often cannot be selected or clicked as a whole (SSH, tmux).
*/
export class AuthUrlComponent extends Container {
readonly url: string;
private readonly tui: TUI;
private readonly hint: Text;
constructor(tui: TUI, url: string) {
super();
this.tui = tui;
this.url = url;
this.addChild(new Text(theme.fg("accent", hyperlink(url, url)), 1, 0));
this.hint = new Text("", 1, 0);
this.addChild(this.hint);
this.setHint(keyHint("app.message.copy", "to copy"));
}
private setHint(suffix: string): void {
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
this.hint.setText(`${theme.fg("dim", hyperlink(clickHint, this.url))} ${theme.fg("dim", "•")} ${suffix}`);
this.tui.requestRender();
}
async copy(): Promise<void> {
try {
await copyToClipboard(this.url);
this.setHint(theme.fg("success", "Copied URL to clipboard"));
} catch (error) {
this.setHint(theme.fg("error", error instanceof Error ? error.message : String(error)));
}
}
}

View file

@ -2,6 +2,7 @@ import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { openBrowser } from "../../../utils/open-browser.ts";
import { theme } from "../theme/theme.ts";
import { AuthUrlComponent } from "./auth-url.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
@ -12,6 +13,8 @@ export class LoginDialogComponent extends Container implements Focusable {
private contentContainer: Container;
private input: Input;
private tui: TUI;
/** The shown sign-in URL, which `app.message.copy` copies. */
private authUrl?: AuthUrlComponent;
private abortController = new AbortController();
private inputResolver?: (value: string) => void;
private inputRejecter?: (error: Error) => void;
@ -96,12 +99,8 @@ export class LoginDialogComponent extends Container implements Focusable {
showAuth(url: string, instructions?: string): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.authUrl = new AuthUrlComponent(this.tui, url);
this.contentContainer.addChild(this.authUrl);
if (instructions) {
this.contentContainer.addChild(new Spacer(1));
@ -116,6 +115,7 @@ export class LoginDialogComponent extends Container implements Focusable {
* Called by onDeviceCode callback - show URL and user code.
*/
showDeviceCode(info: OAuthDeviceCodeInfo): void {
this.authUrl = undefined;
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
@ -177,6 +177,7 @@ export class LoginDialogComponent extends Container implements Focusable {
/** Show informational text before another login step. */
showDetails(lines: string[]): void {
this.authUrl = undefined;
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
for (const line of lines) {
@ -226,6 +227,10 @@ export class LoginDialogComponent extends Container implements Focusable {
this.cancel();
return;
}
if (this.authUrl && kb.matches(data, "app.message.copy")) {
void this.authUrl.copy();
return;
}
// Pass to input
this.input.handleInput(data);

View file

@ -0,0 +1,60 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { McpManagerView } from "../src/extensions/mcp/ui.ts";
import { LoginDialogComponent } from "../src/modes/interactive/components/login-dialog.ts";
import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
const { copyToClipboard } = vi.hoisted(() => ({ copyToClipboard: vi.fn(async (_text: string) => {}) }));
vi.mock("../src/utils/clipboard.ts", () => ({ copyToClipboard }));
vi.mock("../src/utils/open-browser.ts", () => ({ openBrowser: vi.fn() }));
const URL = `https://auth.example.invalid/authorize?${"x".repeat(300)}`;
const CTRL_X = "\x18";
const tui = { requestRender: vi.fn() } as unknown as TUI;
function rendered(component: { render(width: number): string[] }): string {
return stripAnsi(component.render(80).join("\n"));
}
describe("sign-in URL copy key", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
copyToClipboard.mockClear();
});
test("login dialog copies the auth URL instead of typing into the code input", async () => {
const dialog = new LoginDialogComponent(tui, "test", () => {});
dialog.showAuth(URL);
void dialog.showManualInput("Paste the code:");
expect(rendered(dialog)).toContain("ctrl+x to copy");
dialog.handleInput(CTRL_X);
await vi.waitFor(() => expect(rendered(dialog)).toContain("Copied URL to clipboard"));
expect(copyToClipboard).toHaveBeenCalledWith(URL);
});
test("login dialog ignores the copy key without an auth URL", () => {
const dialog = new LoginDialogComponent(tui, "test", () => {});
dialog.showDeviceCode({ userCode: "ABCD", verificationUri: "https://example.invalid/device" });
dialog.handleInput(CTRL_X);
expect(copyToClipboard).not.toHaveBeenCalled();
});
test("MCP sign-in screen copies the authorization URL", async () => {
const view = new McpManagerView(tui, theme, new KeybindingsManager());
void view.redirectUrl("Sign in to issues", URL, new AbortController().signal);
expect(rendered(view)).toContain("ctrl+x to copy");
view.handleInput(CTRL_X);
await vi.waitFor(() => expect(rendered(view)).toContain("Copied URL to clipboard"));
expect(copyToClipboard).toHaveBeenCalledWith(URL);
});
});