import React, { useRef, useState } from "react"; import { __, sprintf } from "@wordpress/i18n"; import CopyField from "./CopyField"; import { IconClaude, IconCode, IconCursor, IconExternal, IconInfo, IconPlug, IconSpark } from "./Icons"; /** * The "Editors & CLI" tab: a rail of five clients on the left, one detail panel * on the right. * * It replaces an earlier two-column grid of five cards. Those cards had * wildly different natural heights — a deep-link button next to a twelve-line * prompt — so the grid stretched the short ones to the tallest in their row and * left the fifth alone in a half-empty one (ADR-057). A picker shows one client * at a time, which is all anyone needs: nobody sets up five editors at once. * * The OAuth tab is untouched and still lives in `ConnectionConfig.js`; its two * cards are the same height and the grid suits them. */ // The green this plugin's design tokens call `--base-color-700`. Written out // because the accents are inline custom properties, not stylesheet values. const BD_GREEN = "#00b884"; // UTF-8 safe base64: btoa() throws on multi-byte characters, and a site title // or token is not guaranteed to be ASCII. const toBase64 = (str) => { try { return window.btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))), ); } catch (e) { return ""; } }; /** * The accent tile. The brand colour arrives as a custom property so the * stylesheet — not this component — decides what happens to it in dark mode, * where Cursor's near-black would otherwise vanish (ADR-054). * * `solid` marks a tile whose glyph is a vendor's own logo file rather than one * of ours: it draws a plain white chip with a hairline border, so the logo keeps * its own colour (ADR-066). Only the clients on this rail that carry somebody * else's logo use it — VS Code, the JSON block and the prompt wear our own * glyphs and keep the tint. */ const Tile = ({ Icon, accent, accentDark, accentFgDark, solid, size = 36 }) => ( ); const Steps = ({ steps }) => (
    {steps.map((step, i) => (
  1. {i + 1} {step}
  2. ))}
); /** * One client's payload, in whichever of the two shapes it has: a copyable * value, or a deep link with a line saying where it goes. */ const Payload = ({ payload }) => payload.href ? (
{payload.cta} {payload.hint &&

{payload.hint}

}
) : (
); export default function EditorsTab({ data }) { const railRef = useRef(null); const [selected, setSelected] = useState("claude-code"); // Per client, and deliberately not remembered: switching client is the // moment to re-offer the safe path, not to carry a token choice across. const [mode, setMode] = useState("browser"); const pick = (id) => { setSelected(id); setMode("browser"); }; const endpoint = data.mcp_endpoint || ""; const endpointRest = data.mcp_endpoint_rest || ""; const token = data.connection_token || ""; // Every browser-mode payload carries no credential: the endpoint answers an // unauthenticated call with `401` + `WWW-Authenticate: Bearer // resource_metadata=…`, which is the challenge that starts the OAuth flow, // and this server accepts dynamic client registration with loopback // redirect URIs (ADR-054). The token variants are built beside them for the // machine that has no browser to open. const oauthEntry = { url: endpoint }; const tokenEntry = { url: endpoint, headers: { Authorization: `Bearer ${token}` } }; const cursorLink = (entry) => `cursor://anysphere.cursor-deeplink/mcp/install?name=betterdocs&config=${encodeURIComponent(toBase64(JSON.stringify(entry)))}`; const vscodeLink = (entry) => `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: "betterdocs", type: "http", ...entry }))}`; const mcpServersBlock = (entry) => JSON.stringify({ mcpServers: { betterdocs: entry } }, null, 2); // Built here rather than server-side: `MCPPairing::config_snippets()` exists // to carry the token, and a string with no secret in it needs no round trip. const claudeCodeOauth = `claude mcp add --transport http betterdocs ${endpoint}`; const jsonHelp = endpointRest ? sprintf( /* translators: %s: the REST fallback endpoint URL. */ __( "Paste into any client that takes a JSON MCP config. If pretty permalinks are off, use %s as the url.", "betterdocs", ), endpointRest, ) : __("Paste into any client that takes a JSON MCP config.", "betterdocs"); // Steps shared between the two modes, so the pair can only differ where it // is meant to: the browser flow has an approval step the token flow does not. const RUN = __("Copy the command above and run it in your terminal.", "betterdocs"); const STATUS = __("Ask Claude to call bd-get-status to confirm the connection.", "betterdocs"); const JSON_COPY = __("Copy the block above into the client's MCP configuration file.", "betterdocs"); const JSON_RESTART = __("Restart the client so it reads the new server.", "betterdocs"); const ASK_COPY = __("Copy the prompt above.", "betterdocs"); const ASK_PASTE = __("Paste it into an assistant that can edit its own MCP configuration.", "betterdocs"); const clients = [ { id: "claude-code", name: "Claude Code", badge: __("CLI", "betterdocs"), // Claude Code is Anthropic's, so it wears Anthropic's mark on // Anthropic's tile — the generic sparkle at Claude's terracotta was // the "tinted glyph where the app icon belongs" this unit removes. Icon: IconClaude, accent: "#d97757", solid: true, desc: __("Add the server from your terminal, approve once in the browser.", "betterdocs"), browser: { label: __("Command", "betterdocs"), value: claudeCodeOauth, steps: [ RUN, __( "Run /mcp in Claude Code and choose BetterDocs — your browser opens this site's approval screen; sign in and approve.", "betterdocs", ), STATUS, ], }, token: { label: __("Command", "betterdocs"), value: data.config?.cli || "", multiline: true, steps: [RUN, STATUS], }, }, { id: "cursor", name: "Cursor", badge: __("Editor", "betterdocs"), Icon: IconCursor, accent: "#0f172a", // Near-black on a dark card is invisible, so the solid tile inverts // there: light square, near-black arrow. accentDark: "#cbd5e1", accentFgDark: "#0f172a", solid: true, desc: __("One click opens Cursor with BetterDocs prefilled.", "betterdocs"), browser: { href: cursorLink(oauthEntry), cta: __("Add to Cursor", "betterdocs"), hint: __("Opens cursor://… with the server URL.", "betterdocs"), steps: [ __('Click "Add to Cursor". It opens Cursor with BetterDocs prefilled.', "betterdocs"), __("Confirm the install when Cursor asks.", "betterdocs"), __("Approve the connection in the browser Cursor opens for this site's sign-in.", "betterdocs"), ], }, token: { href: cursorLink(tokenEntry), cta: __("Add to Cursor", "betterdocs"), hint: __("Opens cursor://… with the server URL and the token.", "betterdocs"), steps: [ __('Click "Add to Cursor". It opens Cursor with BetterDocs prefilled.', "betterdocs"), __("Confirm the install when Cursor asks.", "betterdocs"), ], }, }, { id: "vscode", name: "VS Code", badge: __("Editor", "betterdocs"), Icon: IconCode, accent: "#0098ff", accentDark: "#4db8ff", desc: __("One click opens VS Code with BetterDocs prefilled.", "betterdocs"), browser: { href: vscodeLink(oauthEntry), cta: __("Add to VS Code", "betterdocs"), hint: __("Opens vscode:mcp/install… with the server URL.", "betterdocs"), steps: [ __('Click "Add to VS Code". It opens VS Code with BetterDocs prefilled.', "betterdocs"), __("Confirm the install when VS Code asks.", "betterdocs"), __("Approve the connection in the browser VS Code opens for this site's sign-in.", "betterdocs"), ], }, token: { href: vscodeLink(tokenEntry), cta: __("Add to VS Code", "betterdocs"), hint: __("Opens vscode:mcp/install… with the server URL and the token.", "betterdocs"), steps: [ __('Click "Add to VS Code". It opens VS Code with BetterDocs prefilled.', "betterdocs"), __("Confirm the install when VS Code asks.", "betterdocs"), ], }, }, { id: "json", name: __("Claude Desktop & JSON clients", "betterdocs"), badge: __("JSON", "betterdocs"), badgeTone: "green", Icon: IconPlug, accent: BD_GREEN, desc: __("Paste a block into any client that takes a JSON MCP config.", "betterdocs"), browser: { label: __("mcpServers block", "betterdocs"), value: mcpServersBlock(oauthEntry), multiline: true, help: jsonHelp, steps: [ JSON_COPY, JSON_RESTART, __("Approve the connection in the browser it opens for this site's sign-in.", "betterdocs"), ], }, token: { label: __("mcpServers block", "betterdocs"), value: data.config?.json || "", multiline: true, help: jsonHelp, steps: [JSON_COPY, JSON_RESTART], }, }, { id: "prompt", name: __("Ask your AI to set it up", "betterdocs"), badge: __("Prompt", "betterdocs"), badgeTone: "green", Icon: IconSpark, accent: BD_GREEN, desc: __("Paste a prompt and the assistant adds the connection itself.", "betterdocs"), browser: { label: __("Prompt", "betterdocs"), value: data.ai_prompt || "", multiline: true, help: __( "It carries no credential — the assistant is sent here to sign in.", "betterdocs", ), steps: [ ASK_COPY, ASK_PASTE, __("Approve the connection in the browser it opens, then ask it to call bd-get-status.", "betterdocs"), ], }, token: { label: __("Prompt", "betterdocs"), value: data.ai_prompt_token || "", multiline: true, steps: [ASK_COPY, ASK_PASTE, STATUS], }, }, ]; const current = clients.find((c) => c.id === selected) || clients[0]; const payload = current[mode]; // Roving focus over the rail, the way a `tablist` is meant to behave: the // selected item is the only tab stop, and the arrows move the selection. // The handler sits on the tabs rather than the list, because the tabs are // what hold focus — a `tablist` that is itself focusable would put a second, // useless stop in the page's tab order. const onRailKeyDown = (event) => { const index = clients.findIndex((c) => c.id === selected); let next = null; if (event.key === "ArrowDown" || event.key === "ArrowRight") { next = (index + 1) % clients.length; } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { next = (index - 1 + clients.length) % clients.length; } else if (event.key === "Home") { next = 0; } else if (event.key === "End") { next = clients.length - 1; } if (next === null) return; event.preventDefault(); pick(clients[next].id); const button = railRef.current?.querySelector(`[data-client="${clients[next].id}"]`); if (button) button.focus(); }; return (
{__("Pick your tool", "betterdocs")} {clients.map((c) => ( ))}
{current.name} {current.badge} {current.desc}
{mode === "token" && (

{__( "This carries your site's connection token. Only for a machine with no browser.", "betterdocs", )}

)} {__("How to", "betterdocs")}
); }