Unified · Split
| @@ -111,6 +111,10 @@ export interface IntegrationPreset { · 110 unmodified lines | ||
| 111 | 111 | readonly endpoint?: string; |
| 112 | 112 | /** Optional icon URL (favicon, logo). */ |
| 113 | 113 | readonly icon?: string; |
| 114 | + /** Image to show when `icon` cannot be resolved on this machine — a preset | |
| 115 | + * whose icon is read from a local install has none until that install | |
| 116 | + * exists, which is exactly when the card most needs to identify itself. */ | |
| 117 | + readonly fallbackIcon?: string; | |
| 114 | 118 | /** Shown in the top-level grid on the integrations page when true. */ |
| 115 | 119 | readonly featured?: boolean; |
| 116 | 120 | readonly family?: string; |
| @@ -596,6 +596,10 @@ export interface IntegrationPreset { · 595 unmodified lines | ||
| 596 | 596 | readonly url?: string; |
| 597 | 597 | readonly endpoint?: string; |
| 598 | 598 | readonly icon?: string; |
| 599 | + /** Image to show when `icon` cannot be resolved on this machine — a preset | |
| 600 | + * whose icon is read from a local install has none until that install | |
| 601 | + * exists, which is exactly when the card most needs to identify itself. */ | |
| 602 | + readonly fallbackIcon?: string; | |
| 599 | 603 | readonly featured?: boolean; |
| 600 | 604 | readonly family?: string; |
| 601 | 605 | readonly specFormat?: string; |
| @@ -94,9 +94,14 @@ export default function CodexPluginAdd(props: { · 93 unmodified lines | ||
| 94 | 94 | {/* Mirrors the plugin's own page in Codex: its icon, display name, |
| 95 | 95 | tagline, and long description, all read from the local install. */} |
| 96 | 96 | <div className="flex flex-col gap-4"> |
| 97 | − {plugin.icon !== undefined && ( | |
| 98 | − <img src={plugin.icon} alt="" className="size-16 rounded-2xl" /> | |
| 99 | − )} | |
| 97 | + {/* The plugin's own icon comes from the local Codex install; without | |
| 98 | + one the card still identifies its provider rather than showing a | |
| 99 | + gap, which matters most on the machines that have no install. */} | |
| 100 | + <img | |
| 101 | + src={plugin.icon ?? "https://integrations.sh/logo/openai.com"} | |
| 102 | + alt="" | |
| 103 | + className="size-16 rounded-2xl" | |
| 104 | + /> | |
| 100 | 105 | <div className="min-w-0"> |
| 101 | 106 | <div className="flex items-baseline gap-2"> |
| 102 | 107 | <h1 className="text-xl font-semibold text-foreground"> |
| @@ -127,7 +132,15 @@ export default function CodexPluginAdd(props: { · 24 unmodified lines | ||
| 127 | 132 | </span> |
| 128 | 133 | </div> |
| 129 | 134 | {!plugin.available && plugin.setupHint !== undefined && ( |
| 130 | − <p className="text-[12px] text-muted-foreground">{plugin.setupHint}</p> | |
| 135 | + // The hint arrives as numbered lines; render them as the list they | |
| 136 | + // are, so the reader sees an ordered path rather than a paragraph. | |
| 137 | + <ol className="mt-1 flex list-none flex-col gap-1.5"> | |
| 138 | + {plugin.setupHint.split("\n").map((step) => ( | |
| 139 | + <li key={step} className="text-[12px] leading-relaxed text-muted-foreground"> | |
| 140 | + {step} | |
| 141 | + </li> | |
| 142 | + ))} | |
| 143 | + </ol> | |
| 131 | 144 | )} |
| 132 | 145 | {plugin.available && !added && ( |
| 133 | 146 | <p className="text-[12px] text-muted-foreground"> |
| @@ -32,11 +32,41 @@ export interface CuratedCodexPlugin { · 31 unmodified lines | ||
| 32 | 32 | readonly summary: string; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | −export const CHROME_SETUP_HINT = | |
| 36 | − "Install the Codex app and add the Chrome plugin (Settings \u2192 Computer use installs the ChatGPT browser extension), then use it once inside Codex so it can reach your browser."; | |
| 35 | +/** What to do when a card is not usable yet. | |
| 36 | + * | |
| 37 | + * Written as numbered steps rather than one sentence: a person reading this | |
| 38 | + * has just been told they cannot proceed, and the useful answer is the | |
| 39 | + * shortest ordered path to being able to. Each step names where to go, and | |
| 40 | + * the last one says to come back — otherwise the card is a dead end. The | |
| 41 | + * plugin's own name is substituted in, so the instruction is about the thing | |
| 42 | + * the person clicked rather than about plugins in general. */ | |
| 43 | +export const setupSteps = ( | |
| 44 | + requires: CuratedCodexPlugin["requires"], | |
| 45 | + name: string, | |
| 46 | +): readonly string[] => { | |
| 47 | + const install = "Install the Codex app from openai.com/codex, then sign in."; | |
| 48 | + const finish = "Come back here and add it."; | |
| 49 | + if (requires === "codex") return [install, finish]; | |
| 50 | + if (requires === "chrome-plugin") { | |
| 51 | + return [ | |
| 52 | + install, | |
| 53 | + "In Codex, open Settings \u2192 Computer use and install the ChatGPT browser extension.", | |
| 54 | + "Use Chrome once inside Codex, so it can reach your browser.", | |
| 55 | + finish, | |
| 56 | + ]; | |
| 57 | + } | |
| 58 | + return [ | |
| 59 | + install, | |
| 60 | + `Open ${name} once inside Codex. macOS asks for its permissions the first time \u2014 Full Disk Access, Contacts, and Automation.`, | |
| 61 | + finish, | |
| 62 | + ]; | |
| 63 | +}; | |
| 37 | 64 | |
| 38 | −export const CODEX_SETUP_HINT = | |
| 39 | − "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation)."; | |
| 65 | +/** The steps as one string, for the wire and for plain-text surfaces. */ | |
| 66 | +export const setupHint = (requires: CuratedCodexPlugin["requires"], name: string): string => | |
| 67 | + setupSteps(requires, name) | |
| 68 | + .map((step, index) => `${index + 1}. ${step}`) | |
| 69 | + .join("\n"); | |
| 40 | 70 | |
| 41 | 71 | export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ |
| 42 | 72 | // Names are exactly the plugins' own displayNames — nothing invented, no |
| @@ -174,7 +174,13 @@ describe("scanCodexPlugins", () => { · 173 unmodified lines | ||
| 174 | 174 | expect(byId.get("codex-messages")?.available).toBe(false); |
| 175 | 175 | expect(byId.get("codex-computer-use")?.available).toBe(false); |
| 176 | 176 | expect(byId.get("codex-chrome")?.available).toBe(false); |
| 177 | − expect(byId.get("codex-chrome")?.setupHint).toContain("Chrome plugin"); | |
| 177 | + // Each card's steps are about ITS requirement: Chrome needs the browser | |
| 178 | + // extension, which no other card mentions. | |
| 179 | + expect(byId.get("codex-chrome")?.setupHint).toContain("browser extension"); | |
| 180 | + expect(byId.get("codex-messages")?.setupHint).not.toContain("browser extension"); | |
| 181 | + // And the steps are numbered and name the plugin the person clicked. | |
| 182 | + expect(byId.get("codex-messages")?.setupHint).toContain("1. Install the Codex app"); | |
| 183 | + expect(byId.get("codex-messages")?.setupHint).toContain("Open Messages once inside Codex"); | |
| 178 | 184 | // The docs server ships with Codex itself, so the CLI alone is enough. |
| 179 | 185 | expect(byId.get("codex-openai-docs")?.available).toBe(true); |
| 180 | 186 | }); |
| @@ -20,12 +20,7 @@ import * as path from "node:path"; · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | import { Option, Schema } from "effect"; |
| 22 | 22 | |
| 23 | −import { | |
| 24 | − CHROME_SETUP_HINT, | |
| 25 | − CODEX_SETUP_HINT, | |
| 26 | − CURATED_CODEX_PLUGINS, | |
| 27 | − type CuratedCodexPlugin, | |
| 28 | −} from "./codex-plugin-presets"; | |
| 23 | +import { CURATED_CODEX_PLUGINS, setupHint, type CuratedCodexPlugin } from "./codex-plugin-presets"; | |
| 29 | 24 | |
| 30 | 25 | export interface CodexPluginEntry { |
| 31 | 26 | /** Stable card id, e.g. `codex-messages`. */ |
| @@ -188,9 +183,6 @@ const readText = (file: string): string | undefined => · 156 unmodified lines | ||
| 188 | 183 | const isReadableFile = (file: string): boolean => |
| 189 | 184 | tryOrElse(() => fs.statSync(file).isFile(), false); |
| 190 | 185 | |
| 191 | −const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string => | |
| 192 | − requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT; | |
| 193 | − | |
| 194 | 186 | const isExecutableFile = (file: string): boolean => |
| 195 | 187 | tryOrElse(() => { |
| 196 | 188 | fs.accessSync(file, fs.constants.X_OK); |
| @@ -368,7 +360,7 @@ const scanCachedPlugin = ( · 171 unmodified lines | ||
| 368 | 360 | // process's environment sight-unseen. A user can declare more env on |
| 369 | 361 | // the integration after adding it. |
| 370 | 362 | env: { CODEX_HOME: codexHome }, |
| 371 | − ...(available ? {} : { setupHint: CODEX_SETUP_HINT }), | |
| 363 | + ...(available ? {} : { setupHint: setupHint("codex", displayName) }), | |
| 372 | 364 | ...display, |
| 373 | 365 | }; |
| 374 | 366 | }); |
| @@ -424,7 +416,7 @@ export const scanCodexPlugins = (options?: { · 49 unmodified lines | ||
| 424 | 416 | ...(entry.surface === undefined ? {} : { surface: entry.surface }), |
| 425 | 417 | ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), |
| 426 | 418 | }, |
| 427 | − ...(available ? {} : { setupHint: setupHintFor(entry.requires) }), | |
| 419 | + ...(available ? {} : { setupHint: setupHint(entry.requires, entry.name) }), | |
| 428 | 420 | ...display, |
| 429 | 421 | }; |
| 430 | 422 | }); |
| @@ -852,6 +852,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 851 unmodified lines | ||
| 852 | 852 | ...("url" in preset && preset.url ? { url: preset.url } : {}), |
| 853 | 853 | ...("endpoint" in preset && preset.endpoint ? { endpoint: preset.endpoint } : {}), |
| 854 | 854 | ...(preset.icon ? { icon: preset.icon } : {}), |
| 855 | + ...(preset.fallbackIcon ? { fallbackIcon: preset.fallbackIcon } : {}), | |
| 855 | 856 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 856 | 857 | ...(preset.family ? { family: preset.family } : {}), |
| 857 | 858 | ...("defaultSlug" in preset && preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}), |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets"; |
| 2 | 2 | |
| 3 | 3 | export interface McpRemotePreset { |
| 4 | + /** Image to show when `icon` cannot be resolved on this machine. */ | |
| 5 | + readonly fallbackIcon?: string; | |
| 4 | 6 | readonly id: string; |
| 5 | 7 | readonly name: string; |
| 6 | 8 | readonly summary: string; |
| @@ -13,6 +15,8 @@ export interface McpRemotePreset { · 6 unmodified lines | ||
| 13 | 15 | } |
| 14 | 16 | |
| 15 | 17 | export interface McpStdioPreset { |
| 18 | + /** Image to show when `icon` cannot be resolved on this machine. */ | |
| 19 | + readonly fallbackIcon?: string; | |
| 16 | 20 | readonly id: string; |
| 17 | 21 | readonly name: string; |
| 18 | 22 | readonly summary: string; |
| @@ -41,6 +45,11 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 22 unmodified lines | ||
| 41 | 45 | name: plugin.name, |
| 42 | 46 | summary: plugin.summary, |
| 43 | 47 | icon: `executor:/mcp/codex-plugins/${plugin.id}/icon`, |
| 48 | + // The plugin's own icon lives in the user's Codex install, so a machine | |
| 49 | + // without Codex has none to read. Fall back to the provider's mark from the | |
| 50 | + // same logo service every other preset uses, rather than vendoring OpenAI's | |
| 51 | + // artwork into this repo. | |
| 52 | + fallbackIcon: "https://integrations.sh/logo/openai.com", | |
| 44 | 53 | family: "codex", |
| 45 | 54 | defaultSlug: plugin.slug, |
| 46 | 55 | transport: "stdio", |
| @@ -72,6 +72,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 71 unmodified lines | ||
| 72 | 72 | presetSummary?: string; |
| 73 | 73 | presetUrl?: string; |
| 74 | 74 | presetIcon?: string; |
| 75 | + presetFallbackIcon?: string; | |
| 75 | 76 | }> = []; |
| 76 | 77 | for (const plugin of integrationPlugins) { |
| 77 | 78 | for (const preset of plugin.presets ?? []) { |
| @@ -83,6 +84,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 5 unmodified lines | ||
| 83 | 84 | presetSummary: preset.summary, |
| 84 | 85 | presetUrl: preset.url, |
| 85 | 86 | presetIcon: preset.icon, |
| 87 | + presetFallbackIcon: preset.fallbackIcon, | |
| 86 | 88 | }); |
| 87 | 89 | } |
| 88 | 90 | } |
| @@ -196,6 +198,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 107 unmodified lines | ||
| 196 | 198 | > |
| 197 | 199 | <PresetIcon |
| 198 | 200 | {...(e.presetIcon ? { icon: e.presetIcon } : {})} |
| 201 | + {...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})} | |
| 199 | 202 | className="size-4 shrink-0 object-contain" |
| 200 | 203 | fallback={ |
| 201 | 204 | <span |
| @@ -51,10 +51,16 @@ export const resolveExecutorIcon = (path: string): Promise<string | null> => { · 50 unmodified lines | ||
| 51 | 51 | }; |
| 52 | 52 | |
| 53 | 53 | /** Renders a preset icon, resolving `executor:` scheme icons through the |
| 54 | − * authenticated API. `fallback` shows while loading and when there is no | |
| 55 | − * icon. */ | |
| 54 | + * authenticated API. | |
| 55 | + * | |
| 56 | + * `fallbackSrc` is a plain image URL to use when the machine-local icon is | |
| 57 | + * unavailable — a Codex plugin card still shows its provider's mark on a | |
| 58 | + * machine where Codex is not installed, which is exactly when the card is | |
| 59 | + * most in need of explaining itself. `fallback` is the last resort, for when | |
| 60 | + * there is no image of any kind. */ | |
| 56 | 61 | export function PresetIcon(props: { |
| 57 | 62 | readonly icon?: string; |
| 63 | + readonly fallbackSrc?: string; | |
| 58 | 64 | readonly className?: string; |
| 59 | 65 | readonly fallback?: React.ReactNode; |
| 60 | 66 | }) { |
| @@ -72,7 +78,7 @@ export function PresetIcon(props: { · 11 unmodified lines | ||
| 72 | 78 | }; |
| 73 | 79 | }, [isExecutorIcon, props.icon]); |
| 74 | 80 | |
| 75 | − const src = isExecutorIcon ? fetched : (props.icon ?? null); | |
| 81 | + const src = (isExecutorIcon ? fetched : (props.icon ?? null)) ?? props.fallbackSrc ?? null; | |
| 76 | 82 | if (src === null) return <>{props.fallback ?? null}</>; |
| 77 | 83 | return <img src={src} alt="" className={props.className} loading="lazy" />; |
| 78 | 84 | } |
| @@ -467,6 +467,7 @@ function PresetGrid(props: { · 466 unmodified lines | ||
| 467 | 467 | <CardStackEntryMedia> |
| 468 | 468 | <PresetIcon |
| 469 | 469 | {...(preset.icon ? { icon: preset.icon } : {})} |
| 470 | + {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})} | |
| 470 | 471 | className="size-5 object-contain" |
| 471 | 472 | fallback={ |
| 472 | 473 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> |