diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c65106ad3..33b03af5e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 440c130ec..ed001040d 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -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 | diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 6747db3be..84b42de37 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -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 => { + 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; diff --git a/packages/coding-agent/src/extensions/mcp/ui.ts b/packages/coding-agent/src/extensions/mcp/ui.ts index 6f500ccf4..80f3cb818 100644 --- a/packages/coding-agent/src/extensions/mcp/ui.ts +++ b/packages/coding-agent/src/extensions/mcp/ui.ts @@ -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, diff --git a/packages/coding-agent/src/modes/interactive/components/auth-url.ts b/packages/coding-agent/src/modes/interactive/components/auth-url.ts new file mode 100644 index 000000000..398335aaf --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/auth-url.ts @@ -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 { + 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))); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 88371b9d5..20ab7de48 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -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); diff --git a/packages/coding-agent/test/auth-url-copy.test.ts b/packages/coding-agent/test/auth-url-copy.test.ts new file mode 100644 index 000000000..9068490a6 --- /dev/null +++ b/packages/coding-agent/test/auth-url-copy.test.ts @@ -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); + }); +});