{isHelp ? ( @@ -149,9 +150,9 @@ function RequestCard({ {item.profile_id && ( { if (item.profile_id) @@ -184,7 +185,8 @@ function RequestCard({ key={choice} type="button" size="sm" - variant="secondary" + variant="soft" + className="h-8 rounded-lg text-xs" disabled={busy} data-testid="agent-choice" onClick={() => { @@ -208,10 +210,11 @@ function RequestCard({ { setAnswer(event.target.value); @@ -220,7 +223,7 @@ function RequestCard({ @@ -312,8 +315,8 @@ function SessionButton({ data-testid={`agent-session-${session.session_id}`} onClick={onSelect} className={cn( - "flex w-full cursor-pointer flex-col gap-1 rounded-md px-2 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", - selected && "bg-accent text-accent-foreground", + "flex w-full cursor-pointer flex-col gap-1 rounded-lg px-2 py-2 text-left transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", + selected && "bg-foreground/7 hover:bg-foreground/7", )} > @@ -407,8 +410,8 @@ function SessionList({ onSelectSession(null); }} className={cn( - "flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", - selectedSessionId === null && "bg-accent text-accent-foreground", + "flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", + selectedSessionId === null && "bg-foreground/7 hover:bg-foreground/7", )} > {t("agent.sessions.all")} @@ -549,7 +552,7 @@ function ThreadEntry({ · {time} - + {isHelp ? ( ) : ( @@ -624,14 +627,14 @@ function ProfilePicker({ {selected ?? t("agent.composer.noProfile")} @@ -742,7 +745,7 @@ function Composer({ return ( { event.preventDefault(); void submit(); @@ -763,6 +766,7 @@ function Composer({ ? t("agent.composer.placeholderOne", { agent: sessionName }) : t("agent.composer.placeholderAll") } + variant="soft" className="min-h-14 resize-none text-sm" data-testid="agent-composer-input" onChange={(event) => { @@ -795,7 +799,7 @@ function Composer({ @@ -860,7 +864,7 @@ export function AgentConversation({ onSelectSession={onSelectSession} now={now} /> - + - + {t("agent.pause.noteLabel")} { setNote(event.target.value); @@ -185,8 +190,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) { { setOpen(false); }} @@ -196,7 +202,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) { {t("agent.pause.confirm")} @@ -218,7 +226,7 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) { @@ -234,9 +242,10 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) { { setBusy(true); @@ -342,6 +351,7 @@ export function AgentPage({ > @@ -359,7 +369,8 @@ export function AgentPage({ @@ -378,6 +389,7 @@ export function AgentPage({ > @@ -394,27 +406,22 @@ export function AgentPage({ body={t("agent.setup.waitingBody")} > - - - {t("agent.setup.starterLabel")} - - - - + {t("agent.setup.starterLabel")} + + - {t("agent.setup.starter")} - + code={t("agent.setup.starter")} + copyLabel={t("agent.setup.starterLabel")} + successMessage={t("agent.setup.starterCopied")} + className="font-sans text-xs leading-relaxed" + /> @@ -448,7 +455,7 @@ export function AgentPage({ {remoteOff && ( @@ -456,7 +463,8 @@ export function AgentPage({ @@ -489,148 +497,153 @@ export function AgentPage({ return ( - - - - {subPage ? ( - <> - - {title} - - {description} - > - ) : ( - <> - {title} - {description} - > + + + + + {subPage ? ( + <> + + {title} + + + {description} + + > + ) : ( + <> + {title} + {description} + > + )} + + {blocker === null && ( + + {quota && ( + + {t("agent.header.quota", { + left: quota.left, + limit: quota.limit, + })} + + )} + + + + {t("agent.header.connect")} + + )} - {blocker === null && ( - - {quota && ( - } + + { + changeTab(value as AgentTab); + }} + className="flex min-h-0 flex-1 flex-col" + > + + {TABS.map((tab) => ( + - {t("agent.header.quota", { - left: quota.left, - limit: quota.limit, - })} - - )} - - - - {t("agent.header.connect")} - - - )} - - - {blocker === null && } - - { - changeTab(value as AgentTab); - }} - className="flex min-h-0 flex-1 flex-col" - > - - {TABS.map((tab) => ( - - {t(`agent.tabs.${tab}`)} - {tab === "conversation" && - agentConsole.openRequests.length > 0 && ( - - {agentConsole.openRequests.length} - - )} - - ))} - - - - {conversationBody()} - - - - {blockerPanel ?? ( - - )} - - - - {blockerPanel ?? ( - - )} - - - - {blockerPanel ?? - (automation ? ( - - ) : ( - } - title={t("agent.tabs.recipes")} - body={t("agent.recipes.needsAutomation")} - > - - - - {t("agent.setup.planAction")} - - - + {t(`agent.tabs.${tab}`)} + {tab === "conversation" && + agentConsole.openRequests.length > 0 && ( + + {agentConsole.openRequests.length} + + )} + ))} - - - + + + + {conversationBody()} + + + + {blockerPanel ?? ( + + )} + + + + {blockerPanel ?? ( + + )} + + + + {blockerPanel ?? + (automation ? ( + + ) : ( + } + title={t("agent.tabs.recipes")} + body={t("agent.recipes.needsAutomation")} + > + + + + {t("agent.setup.planAction")} + + + + ))} + + + + ); diff --git a/src/components/agent-profiles.tsx b/src/components/agent-profiles.tsx index ce749aca..bd2c2737 100644 --- a/src/components/agent-profiles.tsx +++ b/src/components/agent-profiles.tsx @@ -6,6 +6,7 @@ import { LuAppWindow, LuHand, LuMessageSquareText, + LuSearch, LuUndo2, } from "react-icons/lu"; import { @@ -13,6 +14,7 @@ import { NoteAction, useTimeFormat, } from "@/components/agent-console-parts"; +import { DATA_TABLE_CLASSES } from "@/components/table-controls"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -41,10 +43,10 @@ import { import { cn } from "@/lib/utils"; const STATE_CLASS: Record = { - held: "border-primary/40 bg-primary/10 text-primary-text", - working: "border-success/40 bg-success/10 text-success-text", - error: "border-destructive/40 bg-destructive/10 text-destructive-text", - idle: "border-border text-muted-foreground", + held: "bg-primary/10 text-primary-text", + working: "bg-success/10 text-success-text", + error: "bg-destructive/10 text-destructive-text", + idle: "bg-foreground/6 text-muted-foreground", }; export function AgentProfiles({ @@ -119,16 +121,20 @@ export function AgentProfiles({ {t("agent.profiles.search")} - { - setQuery(event.target.value); - }} - /> + + + { + setQuery(event.target.value); + }} + /> + ) : ( - - - - - + + + + + - {t("agent.profiles.columns.profile")} - + + {t("agent.profiles.columns.profile")} + + {t("agent.profiles.columns.agent")} - {t("agent.profiles.columns.lastAction")} - + + {t("agent.profiles.columns.lastAction")} + + {t("agent.profiles.columns.result")} - + {t("agent.profiles.columns.when")} - {t("agent.profiles.columns.state")} - + + {t("agent.profiles.columns.state")} + + {t("common.labels.actions")} @@ -222,6 +251,8 @@ export function AgentProfiles({ return ( {t(`agent.profiles.state.${row.state}`)} @@ -338,9 +369,9 @@ export function AgentProfiles({ { @@ -358,9 +389,9 @@ export function AgentProfiles({ { diff --git a/src/components/agent-recipe-recorder.tsx b/src/components/agent-recipe-recorder.tsx index b53dde76..93567005 100644 --- a/src/components/agent-recipe-recorder.tsx +++ b/src/components/agent-recipe-recorder.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { StatusLight } from "@/components/ui/settings-panel"; import { translateBackendError } from "@/lib/backend-errors"; import { getRecipeRecording, @@ -123,7 +124,7 @@ export function RecipeRecorder({ return ( {t("agent.recipes.recording.description")} @@ -131,8 +132,8 @@ export function RecipeRecorder({ {isRecording ? ( - - + + {t("agent.recipes.recording.recording")} @@ -141,8 +142,10 @@ export function RecipeRecorder({ { void stop(); }} @@ -154,7 +157,10 @@ export function RecipeRecorder({ ) : ( - + {t("agent.recipes.recording.hint")} @@ -179,7 +186,9 @@ export function RecipeRecorder({ { void start(); }} diff --git a/src/components/agent-recipe-steps.tsx b/src/components/agent-recipe-steps.tsx index e7dbe472..ea22e902 100644 --- a/src/components/agent-recipe-steps.tsx +++ b/src/components/agent-recipe-steps.tsx @@ -139,7 +139,7 @@ export function RecipeStepsEditor({ // Rows are reordered and removed, so the index is the identity here. // eslint-disable-next-line react/no-array-index-key key={`step-${index}`} - className="flex flex-col gap-2 rounded-md border border-border p-2" + className="flex flex-col gap-2 rounded-lg bg-foreground/4 p-2" data-slot="agent-recipe-step" > @@ -166,9 +166,10 @@ export function RecipeStepsEditor({ { @@ -178,9 +179,10 @@ export function RecipeStepsEditor({ { @@ -190,9 +192,10 @@ export function RecipeStepsEditor({ { @@ -410,7 +413,7 @@ function Field({ const id = useId(); return ( - + {label} {cloneElement(children, { id })} diff --git a/src/components/agent-recipes.tsx b/src/components/agent-recipes.tsx index 21bba7ac..351780e4 100644 --- a/src/components/agent-recipes.tsx +++ b/src/components/agent-recipes.tsx @@ -90,19 +90,19 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { { setEditing("new"); }} > - + {t("agent.recipes.new")} {error !== null && ( - + {translateBackendError(t, error)} )} @@ -152,7 +152,7 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { recipes.map((recipe) => ( @@ -165,8 +165,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { { setEditing(recipe); }} @@ -174,8 +175,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) { {t("common.buttons.edit")} { setPendingRemoval(recipe); }} @@ -262,14 +264,19 @@ function RecipeEditor({ return ( { event.preventDefault(); void handleSave(); }} > - {t("agent.recipes.name")} + + {t("agent.recipes.name")} + - {t("agent.recipes.steps")} + + {t("agent.recipes.steps")} + { setSteps([...steps, emptyStep("navigate")]); @@ -305,10 +315,21 @@ function RecipeEditor({ - + {t("common.buttons.cancel")} - + {isSaving ? t("common.buttons.saving") : t("common.buttons.save")} diff --git a/src/components/code-examples-dialog.tsx b/src/components/code-examples-dialog.tsx new file mode 100644 index 00000000..5406cc2f --- /dev/null +++ b/src/components/code-examples-dialog.tsx @@ -0,0 +1,251 @@ +"use client"; + +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { motion, useReducedMotion } from "motion/react"; +import { type ReactNode, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; +import { CodeSnippet } from "@/components/ui/code-snippet"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ProBadge } from "@/components/ui/pro-badge"; +import { SECTION_LABEL_CLASS } from "@/components/ui/settings-panel"; +import { useInputModality } from "@/hooks/use-input-modality"; +import type { SnippetLanguage } from "@/lib/api-examples"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { cn } from "@/lib/utils"; + +export interface CodeExample { + id: string; + title: string; + description: string; + method?: string; + paid?: boolean; + snippets: Partial>; + response?: string; +} + +const LANGUAGE_ORDER: SnippetLanguage[] = [ + "curl", + "javascript", + "python", + "json", +]; + +function languagesOf(example: CodeExample) { + return LANGUAGE_ORDER.filter((language) => example.snippets[language]); +} + +export function CodeExamplesDialog({ + open, + onOpenChange, + title, + description, + examples, + highlights, + note, + copyMessage, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + examples: CodeExample[]; + highlights: readonly string[]; + note?: ReactNode; + copyMessage: string; +}) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); + const inputModality = useInputModality(); + const animate = !reduceMotion && inputModality === "pointer"; + const markerId = useId(); + const [selectedId, setSelectedId] = useState(examples[0]?.id ?? ""); + // The language is the reader's, not the example's: it stays as they move + // between examples, and an example without it falls back to its own first. + const [language, setLanguage] = useState("curl"); + + const selected = + examples.find((example) => example.id === selectedId) ?? examples[0]; + if (!selected) return null; + + return ( + + + + {title} + {description} + + + + + + {examples.map((example) => { + const active = example.id === selected.id; + return ( + + {active && ( + + )} + {example.method && ( + + {example.method} + + )} + + {example.title} + + {example.paid && } + + ); + })} + + + {examples.map((example) => { + const languages = languagesOf(example); + const shown = languages.includes(language) + ? language + : (languages[0] ?? "curl"); + return ( + + + + {example.title} + {example.paid && ( + + {t("integrations.examples.paid")} + + )} + + + {example.description} + + + + { + setLanguage(value as SnippetLanguage); + }} + className="flex min-w-0 flex-col gap-2" + > + + {languages.map((item) => ( + + {t(`integrations.examples.languages.${item}`)} + + ))} + + {languages.map((item) => { + const snippet = example.snippets[item]; + if (!snippet) return null; + return ( + + + + + + ); + })} + + + {example.response && ( + + + {t("integrations.examples.response")} + + + + )} + + ); + })} + + + + {note && ( + + {note} + + )} + + + ); +} diff --git a/src/components/integration-diagnostics.tsx b/src/components/integration-diagnostics.tsx index 28299fac..74ce63fb 100644 --- a/src/components/integration-diagnostics.tsx +++ b/src/components/integration-diagnostics.tsx @@ -3,9 +3,12 @@ import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; +import { LuActivity } from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; import { OperationFlow } from "@/components/ui/operation-flow"; +import { SettingsPanel } from "@/components/ui/settings-panel"; import { translateBackendError } from "@/lib/backend-errors"; +import { cn } from "@/lib/utils"; interface Diagnostic { configured: boolean; @@ -22,8 +25,10 @@ interface Diagnostic { */ export function IntegrationDiagnostics({ target, + className, }: { target: "api" | "remote"; + className?: string; }) { const { t } = useTranslation(); const [result, setResult] = useState(null); @@ -52,23 +57,33 @@ export function IntegrationDiagnostics({ : "appFeedback.unavailable", ); return ( - - - - {t("appFeedback.connectionTest")} - - + + + {t("appFeedback.connectionTest")} + + + {t( + target === "api" + ? "appFeedback.apiProbeScope" + : "appFeedback.remoteProbeScope", + )} + + + void check()} > - {t(busy ? "appFeedback.checking" : "appFeedback.testConnection")} - + + {t("appFeedback.testConnection")} + {busy ? t("appFeedback.checking") @@ -123,21 +138,16 @@ export function IntegrationDiagnostics({ }) : t("appFeedback.notChecked")} {result?.http_status != null && ( - HTTP {result.http_status} + + HTTP {result.http_status} + )} - - {t( - target === "api" - ? "appFeedback.apiProbeScope" - : "appFeedback.remoteProbeScope", - )} - {error && ( {error} )} - + ); } diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index 264879fc..ef71e529 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -2,12 +2,25 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { Eye, EyeOff } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuCheck, LuCloud, LuPlug, LuTrash2 } from "react-icons/lu"; +import { + LuArrowRight, + LuCloud, + LuEye, + LuEyeOff, + LuPlug, + LuRefreshCw, + LuTrash2, +} from "react-icons/lu"; +import { + type CodeExample, + CodeExamplesDialog, +} from "@/components/code-examples-dialog"; import { IntegrationDiagnostics } from "@/components/integration-diagnostics"; +import { LoadingButton } from "@/components/loading-button"; import { AgentIcon } from "@/components/mcp-agent-icon"; +import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure"; import { AnimatedSwitch } from "@/components/ui/animated-switch"; import { AnimatedTabs, @@ -16,6 +29,9 @@ import { AnimatedTabsTrigger, } from "@/components/ui/animated-tabs"; import { Button } from "@/components/ui/button"; +import { CodeSnippet } from "@/components/ui/code-snippet"; +import { ConfirmationMark } from "@/components/ui/confirmation-mark"; +import { CopyToClipboard } from "@/components/ui/copy-to-clipboard"; import { Dialog, DialogContent, @@ -25,8 +41,26 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { OperationFlow } from "@/components/ui/operation-flow"; +import { + FeatureIcon, + SECTION_LABEL_CLASS, + SettingsPanel, + SettingsRow, + StatusLight, +} from "@/components/ui/settings-panel"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; +import { + curlSnippet, + LOCAL_API_EXAMPLES, + maskToken, + mcpClientConfig, + oneLine, + PROFILE_ID_PLACEHOLDER, + REMOTE_MCP_EXAMPLES, + REQUEST_LANGUAGES, + requestSnippet, +} from "@/lib/api-examples"; import { translateBackendError } from "@/lib/backend-errors"; import { canUseRemoteControl } from "@/lib/entitlements"; import { @@ -42,7 +76,6 @@ import { } from "@/lib/mcp"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; -import { CopyToClipboard } from "./ui/copy-to-clipboard"; interface AppSettings { api_enabled: boolean; @@ -103,6 +136,12 @@ export function IntegrationsDialog({ null, ); const [isRotatingCredential, setIsRotatingCredential] = useState(false); + const [apiExamplesOpen, setApiExamplesOpen] = useState(false); + const [remoteExamplesOpen, setRemoteExamplesOpen] = useState(false); + const apiSwitchId = useId(); + const apiPortId = useId(); + const apiTokenId = useId(); + const remoteSwitchId = useId(); const [activeTab, setActiveTab] = useState(initialTab); // Mod+I re-targets an open dialog through `initialTab`. The tabs are // controlled (see `shownTab`), so a remount would not adopt the new value; @@ -420,22 +459,103 @@ export function IntegrationsDialog({ } }; + const handleSavePort = async () => { + const port = settings.api_port; + if (port < 1 || port > 65535) { + showErrorToast(t("integrations.apiInvalidPort"), { + description: t("integrations.apiInvalidPortDescription"), + }); + return; + } + setIsApiStarting(true); + try { + await invoke("stop_api_server"); + const next = await invoke("save_app_settings", { + settings, + }); + setSettings(next); + const actualPort = await invoke("start_api_server", { port }); + setApiServerPort(actualPort); + if (actualPort !== port) { + showErrorToast(t("integrations.apiPortInUse", { port }), { + description: t("integrations.apiFallbackPort", { port: actualPort }), + }); + } else { + showSuccessToast(t("integrations.apiRunning", { port: actualPort })); + } + } catch (e) { + showErrorToast(t("integrations.apiStartFailed"), { + description: translateBackendError(t, e), + }); + } finally { + setIsApiStarting(false); + } + }; + const credentialPresent = credential?.present ?? false; const credentialPrefix = credentialPrefixOf(credential); - // The example names the credential it expects by its visible prefix; the - // dialog never has the plaintext on screen, so the line is a template either - // way and copying it hands over exactly what is shown. + // The examples name the credential they expect by its visible prefix; the + // dialog never has the plaintext on screen, so the lines are templates + // either way and copying one hands over exactly what is shown. const exampleBearer = credentialPrefix ? t("integrations.remote.credentialPrefix", { prefix: credentialPrefix }) : "${TOKEN}"; - const remoteExampleRequest = [ - `curl -X POST -H "Authorization: Bearer ${exampleBearer}" \\`, - ` -H "Content-Type: application/json" \\`, - ` -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \\`, - ` ${REMOTE_MCP_URL}`, - ].join("\n"); + const remoteTarget = { baseUrl: REMOTE_MCP_URL, token: exampleBearer }; + const remoteQuickStart = oneLine( + curlSnippet(REMOTE_MCP_EXAMPLES[0].request, remoteTarget), + ); const fxLine = fxExportLine(settings.mcp_remote_key); + const apiBaseUrl = `http://127.0.0.1:${apiServerPort ?? settings.api_port}`; + const apiToken = settings.api_token ?? ""; + const maskedApiToken = maskToken(apiToken); + // The local token is this machine's own, so a copied example carries it + // and runs as pasted; the page itself shows only its ends. + const apiDisplayTarget = { baseUrl: apiBaseUrl, token: maskedApiToken }; + const apiCopyTarget = { baseUrl: apiBaseUrl, token: apiToken || "TOKEN" }; + const apiQuickStart = LOCAL_API_EXAMPLES[0].request; + + const apiExamples: CodeExample[] = LOCAL_API_EXAMPLES.map((example) => ({ + id: example.id, + title: t(`integrations.examples.items.${example.id}.title`), + description: t(`integrations.examples.items.${example.id}.description`), + method: example.request.method, + paid: example.paid, + response: example.response, + snippets: Object.fromEntries( + REQUEST_LANGUAGES.map((language) => [ + language, + { + display: requestSnippet(language, example.request, apiDisplayTarget), + copy: requestSnippet(language, example.request, apiCopyTarget), + }, + ]), + ), + })); + + const remoteConfig = mcpClientConfig(REMOTE_MCP_URL, exampleBearer); + const remoteExamples: CodeExample[] = [ + ...REMOTE_MCP_EXAMPLES.map((example) => ({ + id: example.id, + title: t(`integrations.examples.items.${example.id}.title`), + description: t(`integrations.examples.items.${example.id}.description`), + method: example.request.method, + response: example.response, + snippets: Object.fromEntries( + REQUEST_LANGUAGES.map((language) => { + const code = requestSnippet(language, example.request, remoteTarget); + return [language, { display: code, copy: code }]; + }), + ), + })), + { + id: "clientConfig", + title: t("integrations.examples.items.clientConfig.title"), + description: t("integrations.examples.items.clientConfig.description"), + snippets: { json: { display: remoteConfig, copy: remoteConfig } }, + }, + ]; + // Remote control is not something a regular user is told about: the tab // exists only for an account entitled to it, or while the bridge is already // on so it can always be switched off. Nothing else on the page names it. @@ -447,17 +567,20 @@ export function IntegrationsDialog({ activeTab === "remote" && !remoteTabShown ? "api" : activeTab; const clientsGrid = ( - - + + {t("integrations.mcp.clientsLabel")} - - + + {agents.map((agent) => { const busy = busyAgentIds.has(agent.id); return ( @@ -472,28 +595,30 @@ export function IntegrationsDialog({ {agent.connected ? ( - + {t("appFeedback.configured")} void handleRemoveAgent(agent)} aria-label={t("integrations.mcp.removeAriaLabel", { name: agent.display_name, })} > - + ) : ( void handleAddAgent(agent)} > {t("integrations.mcp.add")} @@ -502,7 +627,7 @@ export function IntegrationsDialog({ {agent.connected && ( - + {t("appFeedback.routeDetails")} {fxLine && ( @@ -557,7 +681,20 @@ export function IntegrationsDialog({ ); })} - + + ); + + const examplesButton = (onClick: () => void) => ( + + {t("integrations.examples.open")} + + ); return ( @@ -593,28 +730,25 @@ export function IntegrationsDialog({ )} - - - - - - - - - {t("integrations.apiEnableLabel")} - - - {t("integrations.apiEnableDescription")} - - + + + + + + + + + {t("integrations.apiEnableLabel")} + + + {t("integrations.apiEnableDescription")} + @@ -622,199 +756,175 @@ export function IntegrationsDialog({ } /> - - {apiServerPort && ( - - + + + {t("integrations.apiRunningOn")} - - http://127.0.0.1:{apiServerPort} - + - )} - + + - {settings.api_enabled && ( - <> - - - - {t("integrations.apiPortLabel")} - - - { - setApiPortDraft(e.target.value); - const val = Number.parseInt(e.target.value, 10); - if ( - !Number.isNaN(val) && - val >= 1 && - val <= 65535 - ) { - setSettings({ ...settings, api_port: val }); - } - }} - onBlur={() => { - const val = Number.parseInt(apiPortDraft, 10); - if (Number.isNaN(val) || val < 1 || val > 65535) { - setApiPortDraft(String(settings.api_port)); - } - }} - className="w-24 font-mono" - min={1} - max={65535} - /> - + + + + { + setApiPortDraft(e.target.value); + const val = Number.parseInt(e.target.value, 10); + if ( + !Number.isNaN(val) && + val >= 1 && + val <= 65535 + ) { + setSettings({ ...settings, api_port: val }); } - onClick={async () => { - const port = settings.api_port; - if (port < 1 || port > 65535) { - showErrorToast( - t("integrations.apiInvalidPort"), - { - description: t( - "integrations.apiInvalidPortDescription", - ), - }, - ); - return; - } - setIsApiStarting(true); - try { - await invoke("stop_api_server"); - const next = await invoke( - "save_app_settings", - { settings }, - ); - setSettings(next); - const actualPort = await invoke( - "start_api_server", - { port }, - ); - setApiServerPort(actualPort); - if (actualPort !== port) { - showErrorToast( - t("integrations.apiPortInUse", { port }), - { - description: t( - "integrations.apiFallbackPort", - { port: actualPort }, - ), - }, - ); - } else { - showSuccessToast( - t("integrations.apiRunning", { - port: actualPort, - }), - ); - } - } catch (e) { - showErrorToast( - t("integrations.apiStartFailed"), - { - description: translateBackendError(t, e), - }, - ); - } finally { - setIsApiStarting(false); - } - }} - > - {t("common.buttons.save")} - - - - - - - - {t("integrations.apiTokenLabel")} - - - - - + }} + onBlur={() => { + const val = Number.parseInt(apiPortDraft, 10); + if (Number.isNaN(val) || val < 1 || val > 65535) { + setApiPortDraft(String(settings.api_port)); + } + }} + className="h-8 w-24 font-mono text-xs md:text-xs" + min={1} + max={65535} + /> + void handleSavePort()} + > + {t("common.buttons.save")} + + + + + + { setShowApiToken(!showApiToken); }} > {showApiToken ? ( - + ) : ( - + )} + - - - + + - - - + + + {t("integrations.apiExampleRequest")} - - + + {examplesButton(() => { + setApiExamplesOpen(true); + })} - - {`curl -H "Authorization: Bearer \${TOKEN}" \\ - http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`} - - - > - )} + + + + + + + + {remoteTabShown && ( - - - - - - - - - {t("integrations.remote.enableLabel")} - - - {t("integrations.remote.enableDescription")} - - + + + + + + + + + {t("integrations.remote.enableLabel")} + + + {t("integrations.remote.enableDescription")} + {!isLoggedIn && ( - + {t("integrations.remote.signInRequired")} )} {isLoggedIn && !termsAccepted && ( - + {t("integrations.mcpAcceptTermsFirst")} )} - {remote?.enabled && ( - - + {remote && ( + + + {/* Three states, not two. A refusal the bridge + will keep receiving (an unentitled plan, a taken + slot, a dead credential) re-dials for ever + without ever connecting, and "Connecting as" + over the top of the error underneath it read as + a hang rather than an answer. */} + + + {remote.connected + ? t("integrations.remote.connected") + : remote.lastError + ? t("integrations.remote.notConnected") + : t("integrations.remote.connecting")} + + {/* Only under a phrase that governs it. "Connected + as" and "Connecting as" are open phrases; "Not + connected" is closed, and the id dangling after + it read as a sentence fragment in all ten + locales. */} + {!(remote.lastError && !remote.connected) && ( + + {remote.instanceId} + + )} + + {!remote.connected && remote.lastError && ( + + {translateBackendError(t, remote.lastError)} + )} - /> - - {/* Three states, not two. A refusal the bridge will - keep receiving (an unentitled plan, a taken slot, - a dead credential) re-dials for ever without ever - connecting, and "Connecting as" over the top of the - error underneath it read as a hang rather than an - answer. */} - {remote.connected - ? t("integrations.remote.connected") - : remote.lastError - ? t("integrations.remote.notConnected") - : t("integrations.remote.connecting")} - - {/* Only under a phrase that governs it. "Connected as" - and "Connecting as" are open phrases; "Not connected" - is closed, and the id dangling after it read as a - sentence fragment in all ten locales. */} - {!(remote.lastError && !remote.connected) && ( - - {remote.instanceId} - - )} - - )} - - {remote?.enabled && - !remote.connected && - remote.lastError && ( - - {translateBackendError(t, remote.lastError)} - + )} - + + {/* Gated on entitlement: handing an unentitled customer a URL, a credential or an "Add" button that can only answer 402 is the same wrong-diagnosis trap as telling them their desktop is offline. */} - {remote?.enabled && - (remoteEntitled || !remoteEntitlementKnown) && ( - <> - - - - {t("integrations.remote.credentialLabel")} - - void handleRotateCredential()} - > - {credentialPresent - ? t("integrations.remote.credentialRotate") - : t("integrations.remote.credentialCreate")} - - + + + + {credentialPresent ? ( credentialPrefix && ( - + {t("integrations.remote.credentialPrefix", { prefix: credentialPrefix, })} @@ -916,51 +1023,76 @@ export function IntegrationsDialog({ {t("integrations.remote.credentialNone")} )} - - {t("integrations.remote.credentialHint")} - - - - - - - {t("integrations.remote.endpointLabel")} - - - - void handleRotateCredential()} + > + {credentialPresent && ( + + )} + {credentialPresent + ? t("integrations.remote.credentialRotate") + : t("integrations.remote.credentialCreate")} + + + + - - {t("integrations.remote.endpointDescription")} - - + + - - - - {t("integrations.remote.exampleRequest")} - - - - - {remoteExampleRequest} - + + + + {t("integrations.remote.exampleRequest")} + + {examplesButton(() => { + setRemoteExamplesOpen(true); + })} + + - {clientsGrid} - > - )} + {clientsGrid} + + + + + + )} diff --git a/src/components/ui/code-snippet.tsx b/src/components/ui/code-snippet.tsx new file mode 100644 index 00000000..28bd4e49 --- /dev/null +++ b/src/components/ui/code-snippet.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { ReactNode } from "react"; +import { CopyToClipboard } from "@/components/ui/copy-to-clipboard"; +import { cn } from "@/lib/utils"; + +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Marks the parts of a snippet the reader has to swap for their own value. */ +function withHighlights(code: string, highlights: readonly string[]) { + const tokens = [...new Set(highlights.filter(Boolean))]; + if (tokens.length === 0) return code; + const parts = code.split( + new RegExp(`(${tokens.map(escapeRegExp).join("|")})`, "g"), + ); + const nodes: ReactNode[] = []; + let offset = 0; + parts.forEach((part, index) => { + if (part) { + nodes.push( + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + ); + } + offset += part.length; + }); + return nodes; +} + +interface CodeSnippetProps { + code: string; + /** What the copy button writes, when it differs from what is shown. */ + copyText?: string; + highlights?: readonly string[]; + /** Draws a shell prompt that is never copied. */ + prompt?: boolean; + multiline?: boolean; + /** Wraps long lines, for prose rather than code. */ + wrap?: boolean; + copyable?: boolean; + copyLabel?: string; + successMessage?: string; + className?: string; + "data-testid"?: string; +} + +export function CodeSnippet({ + code, + copyText, + highlights = [], + prompt = false, + multiline = false, + wrap = false, + copyable = true, + copyLabel, + successMessage, + className, + "data-testid": testId, +}: CodeSnippetProps) { + return ( + + + {prompt && ( + + ${" "} + + )} + + {withHighlights(code, highlights)} + + + {copyable && ( + + )} + + ); +} diff --git a/src/components/ui/copy-to-clipboard.tsx b/src/components/ui/copy-to-clipboard.tsx index 05e544c9..42fff937 100644 --- a/src/components/ui/copy-to-clipboard.tsx +++ b/src/components/ui/copy-to-clipboard.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { LuCheck, LuCopy } from "react-icons/lu"; import { Button, type ButtonProps } from "@/components/ui/button"; import { showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; interface CopyToClipboardProps { text: string; @@ -12,6 +13,7 @@ interface CopyToClipboardProps { size?: "default" | "sm" | "lg" | "icon"; className?: string; successMessage?: string; + "aria-label"?: string; } export function CopyToClipboard({ @@ -19,7 +21,8 @@ export function CopyToClipboard({ variant = "outline", size = "icon", className, - successMessage = "Copied to clipboard", + successMessage, + "aria-label": ariaLabel, }: CopyToClipboardProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -28,35 +31,41 @@ export function CopyToClipboard({ try { await navigator.clipboard.writeText(text); setCopied(true); - showSuccessToast(successMessage); + showSuccessToast(successMessage ?? t("toasts.success.copied")); setTimeout(() => { setCopied(false); }, 2000); } catch (error) { console.error("Failed to copy to clipboard:", error); } - }, [text, successMessage]); + }, [text, successMessage, t]); return ( {copied ? t("common.srOnly.copied") : t("common.srOnly.copy")} ); diff --git a/src/components/ui/settings-panel.tsx b/src/components/ui/settings-panel.tsx new file mode 100644 index 00000000..98c3b55d --- /dev/null +++ b/src/components/ui/settings-panel.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +export const SECTION_LABEL_CLASS = + "text-[11px] font-medium tracking-wide text-muted-foreground uppercase"; + +/** One quiet surface for a group of related settings. */ +export function SettingsPanel({ + children, + className, + ...props +}: React.ComponentProps<"section">) { + return ( + + {children} + + ); +} + +/** A setting with its explanation on the left and its control on the right. */ +export function SettingsRow({ + label, + description, + htmlFor, + children, + className, +}: { + label: ReactNode; + description?: ReactNode; + htmlFor?: string; + children: ReactNode; + className?: string; +}) { + return ( + + + + {label} + + {description && ( + + {description} + + )} + + + {children} + + + ); +} + +/** The icon beside a feature's on/off switch; it lights up while the feature runs. */ +export function FeatureIcon({ + children, + active, +}: { + children: ReactNode; + active: boolean; +}) { + return ( + + {children} + + ); +} + +/** A status light. `live` pulses for a connection that is up right now. */ +export function StatusLight({ + tone, + live = false, + className, +}: { + tone: "success" | "warning" | "destructive" | "muted"; + live?: boolean; + className?: string; +}) { + const color = { + success: "bg-success", + warning: "bg-warning", + destructive: "bg-destructive", + muted: "bg-muted-foreground", + }[tone]; + return ( + + {live && ( + + )} + + + ); +} diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 6d5cec62..8e4ea539 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,15 +1,37 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; +import { useFieldVariant } from "@/components/ui/field-variant"; import { cn } from "@/lib/utils"; -export type TextareaProps = React.TextareaHTMLAttributes; +const textareaVariants = cva( + "flex min-h-[80px] w-full px-3 py-2 text-sm transition-[color,background-color,border-color,box-shadow] placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", + { + variants: { + variant: { + default: + "rounded-md border border-input bg-background ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + soft: "rounded-lg bg-foreground/5 hover:bg-foreground/7 focus-visible:bg-foreground/7 focus-visible:ring-2 focus-visible:ring-foreground/15 aria-invalid:bg-destructive/10 aria-invalid:ring-2 aria-invalid:ring-destructive/40", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export type TextareaProps = React.TextareaHTMLAttributes & + VariantProps; const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { + ({ className, variant, ...props }, ref) => { + const inherited = useFieldVariant(); return ( {}).constructor; + +test("curl puts the method, the bearer and the body on their own lines", () => { + assert.equal( + curlSnippet({ method: "GET", path: "/v1/profiles" }, target), + 'curl http://127.0.0.1:10108/v1/profiles \\\n -H "Authorization: Bearer tok"', + ); + assert.equal( + curlSnippet( + { method: "POST", path: "/v1/x", body: { note: "it's" } }, + target, + ), + [ + "curl -X POST http://127.0.0.1:10108/v1/x \\", + ' -H "Authorization: Bearer tok" \\', + ' -H "Content-Type: application/json" \\', + ` -d '{"note":"it'\\''s"}'`, + ].join("\n"), + ); + assert.equal( + curlSnippet({ method: "GET", path: "/openapi.json", auth: false }, target), + "curl http://127.0.0.1:10108/openapi.json", + ); +}); + +test("the JavaScript form is valid code and keeps the body's values", () => { + for (const example of [...LOCAL_API_EXAMPLES, ...REMOTE_MCP_EXAMPLES]) { + const code = javascriptSnippet(example.request, target); + assert.doesNotThrow(() => new AsyncFunction(code), example.id); + } + const code = javascriptSnippet( + { + method: "POST", + path: "/v1/x", + body: { plain: 1, "needs-quotes": [true, null] }, + }, + target, + ); + assert.match(code, /\bplain: 1,/); + assert.match(code, /"needs-quotes": \[/); + assert.equal( + javascriptSnippet({ method: "GET", path: "/o", auth: false }, target), + 'const response = await fetch("http://127.0.0.1:10108/o");\nconsole.log(await response.json());', + ); + assert.match( + javascriptSnippet( + { method: "POST", path: "/k", returnsJson: false }, + target, + ), + /console\.log\(response\.status\);$/, + ); +}); + +test("Python literals use Python's constants", () => { + assert.equal(pythonLiteral(true), "True"); + assert.equal(pythonLiteral(false), "False"); + assert.equal(pythonLiteral(null), "None"); + assert.equal( + pythonLiteral({ a: [1, false], b: {} }), + '{\n "a": [\n 1,\n False,\n ],\n "b": {},\n}', + ); + const code = pythonSnippet( + { method: "PUT", path: "/v1/y", body: { on: true }, returnsJson: false }, + target, + ); + assert.match(code, /^import requests\n\nresponse = requests\.put\(/); + assert.match(code, /json=\{\n {8}"on": True,\n {4}\},/); + assert.match(code, /print\(response\.status_code\)$/); +}); + +test("every request language renders every example", () => { + assert.deepEqual(REQUEST_LANGUAGES, ["curl", "javascript", "python"]); + for (const language of REQUEST_LANGUAGES) { + for (const example of LOCAL_API_EXAMPLES) { + const code = requestSnippet(language, example.request, target); + assert.ok(code.includes(target.baseUrl), `${language} ${example.id}`); + } + } +}); + +test("a one-line command is the same command", () => { + const spec = LOCAL_API_EXAMPLES.find((e) => e.id === "runProfile").request; + assert.equal( + oneLine(curlSnippet(spec, target)), + `curl -X POST http://127.0.0.1:10108/v1/profiles/PROFILE_ID/run -H "Authorization: Bearer tok" -H "Content-Type: application/json" -d '{"url":"https://example.com","headless":false}'`, + ); + assert.equal(oneLine("echo 'a \\ b'"), "echo 'a \\ b'"); +}); + +test("the curl body round-trips to the request body", () => { + for (const example of LOCAL_API_EXAMPLES.filter((e) => e.request.body)) { + const code = curlSnippet(example.request, target); + const body = code.match(/-d '(.*)'$/)[1]; + assert.deepEqual(JSON.parse(body), example.request.body, example.id); + } +}); + +test("local examples only call routes the app publishes", () => { + const published = new Set( + JSON.parse( + readFileSync(new URL("../../sdk/api-paths.json", import.meta.url)), + ).operations.map((operation) => `${operation.method} ${operation.path}`), + ); + for (const example of LOCAL_API_EXAMPLES) { + const { method, path, auth } = example.request; + if (auth === false) { + assert.equal(path, "/openapi.json", example.id); + continue; + } + const route = `${method} ${path.replace(PROFILE_ID_PLACEHOLDER, "{id}")}`; + assert.ok(published.has(route), `${example.id}: ${route}`); + } +}); + +test("only the automation routes are marked paid", () => { + assert.deepEqual( + LOCAL_API_EXAMPLES.filter((example) => example.paid).map((e) => e.id), + ["runProfile", "openUrl", "killProfile"], + ); +}); + +test("a token is shown by its ends, never whole", () => { + assert.equal(maskToken(null), "TOKEN"); + assert.equal(maskToken("short"), "••••••••"); + assert.equal(maskToken("abcd0123456789wxyz"), "abcd…wxyz"); +}); + +test("the client config names the server and carries the bearer", () => { + const config = JSON.parse(mcpClientConfig("https://h/api/mcp", "k…")); + assert.deepEqual(config.mcpServers["donut-browser"], { + type: "http", + url: "https://h/api/mcp", + headers: { Authorization: "Bearer k…" }, + }); +}); diff --git a/src/lib/api-examples.ts b/src/lib/api-examples.ts new file mode 100644 index 00000000..0c02a732 --- /dev/null +++ b/src/lib/api-examples.ts @@ -0,0 +1,383 @@ +export type RequestLanguage = "curl" | "javascript" | "python"; + +export type SnippetLanguage = RequestLanguage | "json"; + +export const REQUEST_LANGUAGES: readonly RequestLanguage[] = [ + "curl", + "javascript", + "python", +]; + +export const PROFILE_ID_PLACEHOLDER = "PROFILE_ID"; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface RequestSpec { + method: "GET" | "POST" | "PUT" | "DELETE"; + /** Joined onto the base URL as is, so it may be empty. */ + path: string; + body?: { [key: string]: JsonValue }; + /** False for the few routes that answer without the bearer token. */ + auth?: boolean; + /** False when the route answers with a status and no body. */ + returnsJson?: boolean; +} + +export interface RequestTarget { + baseUrl: string; + token: string; +} + +const INDENT = " "; +const PY_INDENT = " "; +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +function urlOf(spec: RequestSpec, target: RequestTarget): string { + return `${target.baseUrl}${spec.path}`; +} + +function shellQuote(text: string): string { + return `'${text.replaceAll("'", `'\\''`)}'`; +} + +export function curlSnippet(spec: RequestSpec, target: RequestTarget): string { + const head = + spec.method === "GET" + ? `curl ${urlOf(spec, target)}` + : `curl -X ${spec.method} ${urlOf(spec, target)}`; + const flags: string[] = []; + if (spec.auth !== false) { + flags.push(`-H "Authorization: Bearer ${target.token}"`); + } + if (spec.body) { + flags.push(`-H "Content-Type: application/json"`); + flags.push(`-d ${shellQuote(JSON.stringify(spec.body))}`); + } + return [head, ...flags.map((flag) => `${INDENT}${flag}`)].join(" \\\n"); +} + +function jsLiteral(value: JsonValue, depth: number): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + const pad = INDENT.repeat(depth + 1); + const close = INDENT.repeat(depth); + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + return `[\n${value.map((item) => `${pad}${jsLiteral(item, depth + 1)},`).join("\n")}\n${close}]`; + } + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + return `{\n${entries + .map( + ([key, item]) => + `${pad}${IDENTIFIER.test(key) ? key : JSON.stringify(key)}: ${jsLiteral(item, depth + 1)},`, + ) + .join("\n")}\n${close}}`; +} + +export function javascriptSnippet( + spec: RequestSpec, + target: RequestTarget, +): string { + const options: string[] = []; + if (spec.method !== "GET") options.push(`method: "${spec.method}",`); + const headers: string[] = []; + if (spec.auth !== false) { + headers.push(`Authorization: "Bearer ${target.token}",`); + } + if (spec.body) headers.push(`"Content-Type": "application/json",`); + if (headers.length > 0) { + options.push( + `headers: {\n${headers.map((line) => `${INDENT.repeat(2)}${line}`).join("\n")}\n${INDENT}},`, + ); + } + if (spec.body) + options.push(`body: JSON.stringify(${jsLiteral(spec.body, 1)}),`); + const url = JSON.stringify(urlOf(spec, target)); + const call = + options.length === 0 + ? `const response = await fetch(${url});` + : `const response = await fetch(${url}, {\n${options.map((line) => `${INDENT}${line}`).join("\n")}\n});`; + const result = + spec.returnsJson === false + ? "console.log(response.status);" + : "console.log(await response.json());"; + return `${call}\n${result}`; +} + +export function pythonLiteral(value: JsonValue, depth = 0): string { + if (value === null) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + if (typeof value !== "object") return JSON.stringify(value); + const pad = PY_INDENT.repeat(depth + 1); + const close = PY_INDENT.repeat(depth); + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + return `[\n${value.map((item) => `${pad}${pythonLiteral(item, depth + 1)},`).join("\n")}\n${close}]`; + } + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + return `{\n${entries + .map( + ([key, item]) => + `${pad}${JSON.stringify(key)}: ${pythonLiteral(item, depth + 1)},`, + ) + .join("\n")}\n${close}}`; +} + +export function pythonSnippet( + spec: RequestSpec, + target: RequestTarget, +): string { + const args = [JSON.stringify(urlOf(spec, target))]; + if (spec.auth !== false) { + args.push(`headers={"Authorization": "Bearer ${target.token}"}`); + } + if (spec.body) args.push(`json=${pythonLiteral(spec.body, 1)}`); + const result = + spec.returnsJson === false + ? "print(response.status_code)" + : "print(response.json())"; + return [ + "import requests", + "", + `response = requests.${spec.method.toLowerCase()}(`, + ...args.map((arg) => `${PY_INDENT}${arg},`), + ")", + result, + ].join("\n"); +} + +export function requestSnippet( + language: RequestLanguage, + spec: RequestSpec, + target: RequestTarget, +): string { + switch (language) { + case "curl": + return curlSnippet(spec, target); + case "javascript": + return javascriptSnippet(spec, target); + case "python": + return pythonSnippet(spec, target); + } +} + +/** A continued shell command on one line, for places with room for one. */ +export function oneLine(command: string): string { + return command.replace(/ \\\n\s*/g, " "); +} + +/** + * A token is shown by its ends only. The copy carries the real value, so the + * visible form only has to say whose token it is. + */ +export function maskToken(token: string | null | undefined): string { + if (!token) return "TOKEN"; + if (token.length <= 12) return "•".repeat(8); + return `${token.slice(0, 4)}…${token.slice(-4)}`; +} + +export type LocalApiExampleId = + | "listProfiles" + | "runProfile" + | "openUrl" + | "killProfile" + | "createProxy" + | "openApi"; + +export interface ApiExample { + id: Id; + request: RequestSpec; + /** Needs a paid plan with browser automation; the route answers 402 otherwise. */ + paid?: boolean; + response: string; +} + +export const LOCAL_API_EXAMPLES: readonly ApiExample[] = [ + { + id: "listProfiles", + request: { method: "GET", path: "/v1/profiles" }, + response: JSON.stringify( + { + profiles: [ + { + id: "6f1c…", + name: "Shopping", + browser: "wayfern", + proxy_id: null, + tags: ["eu"], + is_running: false, + }, + ], + total: 1, + }, + null, + 2, + ), + }, + { + id: "runProfile", + paid: true, + request: { + method: "POST", + path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/run`, + body: { url: "https://example.com", headless: false }, + }, + response: JSON.stringify( + { + profile_id: "6f1c…", + remote_debugging_port: 9222, + headless: false, + }, + null, + 2, + ), + }, + { + id: "openUrl", + paid: true, + request: { + method: "POST", + path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/open-url`, + body: { url: "https://example.com/account" }, + returnsJson: false, + }, + response: "200 OK", + }, + { + id: "killProfile", + paid: true, + request: { + method: "POST", + path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/kill`, + returnsJson: false, + }, + response: "204 No Content", + }, + { + id: "createProxy", + request: { + method: "POST", + path: "/v1/proxies", + body: { + name: "Residential EU", + proxy_settings: { + proxy_type: "http", + host: "proxy.example.com", + port: 8080, + username: "user", + password: "secret", + }, + }, + }, + response: JSON.stringify( + { + id: "a29d…", + name: "Residential EU", + proxy_settings: { + proxy_type: "http", + host: "proxy.example.com", + port: 8080, + username: "user", + password: "secret", + }, + }, + null, + 2, + ), + }, + { + id: "openApi", + request: { method: "GET", path: "/openapi.json", auth: false }, + response: JSON.stringify( + { + openapi: "3.1.0", + info: { title: "donutbrowser", version: "…" }, + paths: { "/v1/profiles": {} }, + }, + null, + 2, + ), + }, +]; + +export type RemoteMcpExampleId = "listTools" | "callTool" | "clientConfig"; + +export const MCP_SERVER_NAME = "donut-browser"; + +export const REMOTE_MCP_EXAMPLES: readonly ApiExample< + Exclude +>[] = [ + { + id: "listTools", + request: { + method: "POST", + path: "", + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }, + response: JSON.stringify( + { + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "list_profiles", + description: "List all Wayfern browser profiles", + }, + ], + }, + }, + null, + 2, + ), + }, + { + id: "callTool", + request: { + method: "POST", + path: "", + body: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "list_profiles", arguments: {} }, + }, + }, + response: JSON.stringify( + { + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "[ … ]" }] }, + }, + null, + 2, + ), + }, +]; + +/** The entry most MCP clients accept in their `mcpServers` map. */ +export function mcpClientConfig(url: string, token: string): string { + return JSON.stringify( + { + mcpServers: { + [MCP_SERVER_NAME]: { + type: "http", + url, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ); +}