mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
c450f2c0f8
commit
ced72c2f09
7 changed files with 162 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
60
packages/coding-agent/test/auth-url-copy.test.ts
Normal file
60
packages/coding-agent/test/auth-url-copy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue