Files
pi-agent-integrated/pi-web/components/PluginsConfig.tsx
T

994 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { sendAgentCommand } from "@/lib/agent-client";
import { useIsMobile } from "@/hooks/useIsMobile";
import type { PluginPackageInfo, PluginsResponse } from "@/lib/api-types";
import { useI18n } from "@/hooks/useI18n";
type PluginScope = PluginPackageInfo["scope"];
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
function shortenPath(path: string): string {
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
}
function packageKey(pkg: Pick<PluginPackageInfo, "source" | "scope">): string {
return `${pkg.scope}\0${pkg.source}`;
}
function resourceSummary(pkg: PluginPackageInfo, t: ReturnType<typeof useI18n>["t"]): string {
if (pkg.disabled) return t("i18n.disabled");
const parts = [
pkg.counts.extensions ? t("i18n.resourceCount", { count: pkg.counts.extensions, label: t("i18n.extensionShort") }) : "",
pkg.counts.skills ? t("i18n.resourceCount", { count: pkg.counts.skills, label: t("i18n.skillShort") }) : "",
pkg.counts.prompts ? t("i18n.resourceCount", { count: pkg.counts.prompts, label: t("i18n.promptShort") }) : "",
pkg.counts.themes ? t("i18n.resourceCount", { count: pkg.counts.themes, label: t("i18n.themeShort") }) : "",
].filter(Boolean);
return parts.length ? parts.join(" · ") : t("i18n.noResources");
}
function versionSummary(pkg: PluginPackageInfo, t: ReturnType<typeof useI18n>["t"]): string {
const parts = [];
if (pkg.version) parts.push(t("i18n.installedVersion", { version: pkg.version }));
if (pkg.configuredVersion) parts.push(t("i18n.configuredVersion", { version: pkg.configuredVersion }));
return parts.length ? parts.join(" · ") : t("i18n.unknown");
}
function installLocation(scope: PluginScope, cwd: string): string {
return scope === "project"
? `${shortenPath(cwd)}/.pi/agent/{npm,git}`
: "data/agent/{npm,git}";
}
function findInstalledPackage(
packages: PluginPackageInfo[],
source: string,
scope: PluginScope,
): PluginPackageInfo | undefined {
const trimmed = source.trim();
const withoutNpmPrefix = trimmed.startsWith("npm:") ? trimmed.slice(4) : trimmed;
return packages.find((pkg) => pkg.scope === scope && pkg.source === trimmed)
?? packages.find((pkg) => pkg.scope === scope && pkg.source === `npm:${withoutNpmPrefix}`)
?? packages.find((pkg) => pkg.scope === scope && pkg.source.endsWith(trimmed));
}
function statusColor(status: PluginPackageInfo["status"]): string {
if (status === "loaded") return "var(--accent)";
if (status === "installed") return "#f59e0b";
if (status === "disabled") return "var(--text-dim)";
return "#ef4444";
}
function ResourceList({ pkg }: { pkg: PluginPackageInfo }) {
const { t } = useI18n();
const groups = ([
["extension", t("i18n.extensions")],
["skill", t("i18n.skills")],
["prompt", t("i18n.prompts")],
["theme", t("i18n.themes")],
] as const)
.map(([kind, label]) => ({
kind,
label,
resources: pkg.resources.filter((resource) => resource.kind === kind),
}))
.filter((group) => group.resources.length > 0);
if (groups.length === 0) {
return (
<div style={{ fontSize: 12, color: "var(--text-dim)" }}>
{pkg.disabled ? t("i18n.packageDisabled") : t("i18n.noResolvedResources")}
</div>
);
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{groups.map((group, groupIndex) => (
<div
key={group.kind}
style={{
borderTop: groupIndex === 0 ? "none" : "1px solid var(--border)",
paddingTop: groupIndex === 0 ? 0 : 12,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 700,
color: "var(--text-dim)",
textTransform: "uppercase",
marginBottom: 6,
}}
>
{group.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{group.resources.map((resource) => (
<div key={`${resource.kind}:${resource.path}`} style={{ minWidth: 0 }}>
<div
style={{
fontSize: 12,
color: "var(--text)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={resource.path}
>
{resource.name}
</div>
<div
style={{
fontSize: 10,
color: "var(--text-dim)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 1,
}}
title={resource.path}
>
{resource.relativePath}
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
function ScopeTag({ scope }: { scope: PluginScope }) {
return (
<span
style={{
fontSize: 10,
padding: "1px 5px",
borderRadius: 3,
flexShrink: 0,
background: scope === "project" ? "rgba(99,102,241,0.12)" : "rgba(120,120,120,0.12)",
color: scope === "project" ? "rgba(99,102,241,0.85)" : "var(--text-dim)",
}}
>
{scope === "global" ? "app" : scope}
</span>
);
}
function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties {
return {
padding: "6px 12px",
background: danger ? "rgba(239,68,68,0.08)" : "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: danger ? "#ef4444" : "var(--text-muted)",
cursor: disabled ? "not-allowed" : "pointer",
fontSize: 12,
opacity: disabled ? 0.5 : 1,
};
}
function Toggle({
enabled,
loading,
onToggle,
label,
}: {
enabled: boolean;
loading: boolean;
onToggle: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={loading}
title={label}
aria-label={label}
aria-pressed={enabled}
style={{
flexShrink: 0,
width: 40,
height: 22,
borderRadius: 11,
border: "none",
padding: 0,
cursor: loading ? "wait" : "pointer",
background: enabled ? "var(--accent)" : "var(--border)",
position: "relative",
transition: "background 0.18s",
outline: "none",
opacity: loading ? 0.65 : 1,
}}
>
<span
style={{
position: "absolute",
top: 3,
left: enabled ? 21 : 3,
width: 16,
height: 16,
borderRadius: "50%",
background: "var(--bg)",
boxShadow: "0 1px 4px rgba(0,0,0,0.22)",
transition: "left 0.18s cubic-bezier(.4,0,.2,1)",
}}
/>
</button>
);
}
function AppScope() {
return (
<div
style={{
display: "inline-flex",
border: "1px solid var(--border)",
borderRadius: 7,
overflow: "hidden",
height: 30,
}}
>
<span style={{ width: 76, textAlign: "center", paddingTop: 6, color: "var(--text)", fontSize: 12 }}>
app
</span>
</div>
);
}
function AddPluginPanel({
cwd,
source,
scope,
busy,
actionError,
onSourceChange,
onInstall,
}: {
cwd: string;
source: string;
scope: PluginScope;
busy: boolean;
actionError: string | null;
onSourceChange: (value: string) => void;
onInstall: () => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const examples = ["npm:@scope/pi-plugin", "git:https://github.com/user/repo", "/absolute/path/to/plugin"];
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 660, minHeight: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--text)" }}>
{t("i18n.addPlugin")}
</div>
<div style={{ fontSize: 12, color: "var(--text-dim)", fontFamily: "var(--font-mono)" }}>
{installLocation(scope, cwd)}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<label htmlFor="plugin-source" style={{ fontSize: 12, fontWeight: 600, color: "var(--text-muted)" }}>
Source
</label>
<input
id="plugin-source"
ref={inputRef}
value={source}
onChange={(e) => onSourceChange(e.target.value)}
placeholder="npm:@scope/package"
style={{
width: "100%",
height: 36,
padding: "0 11px",
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--bg-panel)",
color: "var(--text)",
fontFamily: "var(--font-mono)",
fontSize: 13,
outline: "none",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && source.trim() && !busy) onInstall();
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<AppScope />
<button
type="button"
onClick={onInstall}
disabled={busy || !source.trim()}
style={{
...buttonStyle(busy || !source.trim()),
background: "var(--accent)",
color: "white",
borderColor: "var(--accent)",
}}
>
{busy ? t("i18n.installing") : t("i18n.install")}
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-muted)" }}>
Examples
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{examples.map((example) => (
<button
key={example}
type="button"
onClick={() => onSourceChange(example)}
style={{
width: "100%",
minHeight: 30,
textAlign: "left",
padding: "6px 9px",
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--bg-panel)",
color: "var(--text-dim)",
cursor: "pointer",
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text-muted)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--bg-panel)";
e.currentTarget.style.color = "var(--text-dim)";
}}
>
{example}
</button>
))}
</div>
</div>
{actionError && (
<div style={{ fontSize: 12, color: "#ef4444", whiteSpace: "pre-wrap" }}>
{actionError}
</div>
)}
</div>
);
}
function PackageDetail({
pkg,
cwd,
busyKey,
actionError,
actionMessage,
sessionId,
onAction,
onReloadSession,
}: {
pkg: PluginPackageInfo;
cwd: string;
busyKey: string | null;
actionError: string | null;
actionMessage: string | null;
sessionId: string | null;
onAction: (action: PluginAction, pkg: PluginPackageInfo) => void;
onReloadSession: () => void;
}) {
const { t } = useI18n();
const key = packageKey(pkg);
const busy = busyKey?.endsWith(key) ?? false;
const reloadBusy = busyKey === "reload";
const enabled = !pkg.disabled;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20, maxWidth: 680 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, minWidth: 0, flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 180, flex: 1 }}>
<Toggle
enabled={enabled}
loading={busy || reloadBusy}
onToggle={() => onAction(pkg.disabled ? "enable" : "disable", pkg)}
label={pkg.disabled ? t("i18n.enablePackage") : t("i18n.disablePackage")}
/>
<ScopeTag scope={pkg.scope} />
{pkg.disabled ? (
<span
style={{
fontSize: 10,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(120,120,120,0.12)",
color: "var(--text-dim)",
}}
>
{t("i18n.disabled")}
</span>
) : pkg.filtered && (
<span
style={{
fontSize: 10,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(245,158,11,0.12)",
color: "#d97706",
}}
>
{t("i18n.filtered")}
</span>
)}
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{pkg.source}
</span>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
onClick={() => onAction("update", pkg)}
disabled={busy || reloadBusy}
style={buttonStyle(busy || reloadBusy)}
>
{busyKey === `update:${key}` ? t("i18n.updating") : t("i18n.update")}
</button>
<button
onClick={onReloadSession}
disabled={!sessionId || reloadBusy || busy}
style={buttonStyle(!sessionId || reloadBusy || busy)}
title={sessionId ? t("i18n.reloadSession") : t("i18n.openSessionToReload")}
>
{reloadBusy ? t("i18n.reloading") : t("i18n.reloadSession")}
</button>
<button
onClick={() => onAction("remove", pkg)}
disabled={busy || reloadBusy}
style={buttonStyle(busy || reloadBusy, true)}
>
{busyKey === `remove:${key}` ? t("i18n.removing") : t("i18n.remove")}
</button>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(96px, 130px) minmax(0, 1fr)",
gap: "9px 14px",
fontSize: 12,
lineHeight: 1.45,
}}
>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.status")}</div>
<div style={{ color: statusColor(pkg.status), textTransform: "capitalize" }}>{pkg.status}</div>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.version")}</div>
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{versionSummary(pkg, t)}</div>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.package")}</div>
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>
{pkg.packageName ?? t("i18n.unknown")}
</div>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.resources")}</div>
<div style={{ color: "var(--text-muted)" }}>{resourceSummary(pkg, t)}</div>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.installedPath")}</div>
<div
style={{
color: pkg.installedPath ? "var(--text-muted)" : "#ef4444",
fontFamily: "var(--font-mono)",
overflowWrap: "anywhere",
}}
>
{pkg.installedPath ? shortenPath(pkg.installedPath) : t("i18n.notFound")}
</div>
<div style={{ color: "var(--text-dim)" }}>{t("i18n.cwd")}</div>
<div style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>
{shortenPath(cwd)}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "var(--text)" }}>
{t("i18n.resolvedResources")}
</div>
<ResourceList pkg={pkg} />
</div>
{actionMessage && (
<div style={{ fontSize: 12, color: "#16a34a" }}>
{actionMessage}
</div>
)}
{actionError && (
<div style={{ fontSize: 12, color: "#ef4444", whiteSpace: "pre-wrap" }}>
{actionError}
</div>
)}
</div>
);
}
export function PluginsConfig({
cwd,
sessionId,
onClose,
onReloaded,
}: {
cwd: string;
sessionId: string | null;
onClose: () => void;
onReloaded?: () => void;
}) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [data, setData] = useState<PluginsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [addMode, setAddMode] = useState(false);
const [installSource, setInstallSource] = useState("");
const installScope: PluginScope = "global";
const [busyKey, setBusyKey] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const packages = useMemo(() => data?.packages ?? [], [data?.packages]);
const selectedPackage = packages.find((pkg) => packageKey(pkg) === selected) ?? null;
const groupedPackages = useMemo(() => {
return (["global"] as PluginScope[])
.map((scope) => ({ scope, packages: packages.filter((pkg) => pkg.scope === scope) }))
.filter((group) => group.packages.length > 0);
}, [packages]);
const loadPlugins = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/plugins?cwd=${encodeURIComponent(cwd)}`);
const next = (await res.json()) as PluginsResponse & { error?: string };
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
setData(next);
setAddMode((current) => next.packages.length === 0 || current);
setSelected((current) => {
if (current && next.packages.some((pkg) => packageKey(pkg) === current)) return current;
return next.packages[0] ? packageKey(next.packages[0]) : null;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [cwd]);
useEffect(() => {
void loadPlugins();
}, [loadPlugins]);
const runAction = useCallback(async (action: PluginAction, pkg: PluginPackageInfo) => {
const key = packageKey(pkg);
setBusyKey(`${action}:${key}`);
setActionError(null);
setActionMessage(null);
try {
const res = await fetch("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, source: pkg.source, scope: pkg.scope, cwd }),
});
const next = (await res.json()) as PluginsResponse & { error?: string };
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
setData(next);
if (action === "remove") {
setSelected(next.packages[0] ? packageKey(next.packages[0]) : null);
if (next.packages.length === 0) setAddMode(true);
setActionMessage("Package removed.");
} else {
const messages: Record<Exclude<PluginAction, "remove">, string> = {
install: "Package installed.",
update: "Package updated.",
disable: "Package disabled.",
enable: "Package enabled.",
};
setActionMessage(messages[action]);
}
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
}, [cwd]);
const installPlugin = useCallback(async () => {
const source = installSource.trim();
if (!source) return;
const key = `${installScope}\0${source}`;
setBusyKey(`install:${key}`);
setActionError(null);
setActionMessage(null);
try {
const res = await fetch("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "install", source, scope: installScope, cwd }),
});
const next = (await res.json()) as PluginsResponse & { error?: string };
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
setData(next);
const installed = findInstalledPackage(next.packages, source, installScope);
setSelected(installed ? packageKey(installed) : key);
setAddMode(false);
setInstallSource("");
setActionMessage("Package installed.");
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
}, [cwd, installScope, installSource]);
const reloadSession = useCallback(async () => {
if (!sessionId) return;
setBusyKey("reload");
setActionError(null);
setActionMessage(null);
try {
await sendAgentCommand(sessionId, { type: "reload" });
onReloaded?.();
await loadPlugins();
setActionMessage("Session reloaded.");
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
}, [loadPlugins, onReloaded, sessionId]);
const addBusy = busyKey?.startsWith("install:") ?? false;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 860,
maxWidth: "calc(100vw - 16px)",
height: isMobile ? "calc(100dvh - 16px)" : "76vh",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 18px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>
{t("common.plugins")}
</span>
<code
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{shortenPath(cwd)}
</code>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
padding: "2px 6px",
}}
>
×
</button>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
<div
style={{
width: isMobile ? "100%" : 245,
maxHeight: isMobile ? "40vh" : undefined,
borderRight: isMobile ? "none" : "1px solid var(--border)",
borderBottom: isMobile ? "1px solid var(--border)" : "none",
display: "flex",
flexDirection: "column",
flexShrink: 0,
background: "var(--bg-panel)",
}}
>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{loading ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>
Loading...
</div>
) : error ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "#ef4444" }}>
{error}
</div>
) : packages.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>
No plugins configured
</div>
) : (
groupedPackages.map((group) => (
<div key={group.scope} style={{ marginBottom: 6 }}>
<div
style={{
padding: "4px 8px 3px",
fontSize: 10,
fontWeight: 600,
color: "var(--text-dim)",
textTransform: "uppercase",
}}
>
{group.scope === "global" ? "app" : group.scope}
</div>
{group.packages.map((pkg) => {
const key = packageKey(pkg);
const isSelected = !addMode && selected === key;
return (
<div
key={key}
onClick={() => {
setSelected(key);
setAddMode(false);
setActionError(null);
setActionMessage(null);
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "8px 8px",
borderRadius: 5,
cursor: "pointer",
background: isSelected ? "var(--bg-selected)" : "none",
}}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = "none";
}}
>
<span
style={{
flexShrink: 0,
width: 7,
height: 7,
borderRadius: "50%",
background: statusColor(pkg.status),
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: "var(--text)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{pkg.source}
</div>
<div
style={{
fontSize: 10,
color: "var(--text-dim)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
>
{resourceSummary(pkg, t)}
</div>
{(pkg.version || pkg.configuredVersion) && (
<div
style={{
fontSize: 10,
color: "var(--text-dim)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
>
{versionSummary(pkg, t)}
</div>
)}
</div>
</div>
);
})}
</div>
))
)}
</div>
<div style={{ padding: "8px 6px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
<button
type="button"
onClick={() => {
setAddMode(true);
setActionError(null);
setActionMessage(null);
}}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 8px",
borderRadius: 5,
border: "none",
width: "100%",
cursor: "pointer",
background: addMode ? "var(--bg-selected)" : "none",
color: addMode ? "var(--accent)" : "var(--text-dim)",
fontSize: 12,
}}
onMouseEnter={(e) => {
if (!addMode) e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!addMode) e.currentTarget.style.background = "none";
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t("i18n.addPlugin")}
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{addMode ? (
<AddPluginPanel
cwd={cwd}
source={installSource}
scope={installScope}
busy={addBusy}
actionError={actionError}
onSourceChange={setInstallSource}
onInstall={installPlugin}
/>
) : loading ? null : selectedPackage ? (
<PackageDetail
key={packageKey(selectedPackage)}
pkg={selectedPackage}
cwd={cwd}
busyKey={busyKey}
actionError={actionError}
actionMessage={actionMessage}
sessionId={sessionId}
onAction={runAction}
onReloadSession={reloadSession}
/>
) : (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-dim)",
fontSize: 13,
}}
>
{t("i18n.selectPackage")}
</div>
)}
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "10px 18px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden" }}>
{data?.diagnostics.length ? (
<span
title={data.diagnostics.map((d) => `${d.type}: ${d.source ? `${d.source}: ` : ""}${d.message}`).join("\n")}
style={{ color: data.diagnostics.some((d) => d.type === "error") ? "#ef4444" : "#d97706" }}
>
{data.diagnostics.length} diagnostic{data.diagnostics.length === 1 ? "" : "s"}
</span>
) : (
<span>
{data ? `${data.totals.extensions} ext · ${data.totals.skills} skills · ${data.totals.prompts} prompts · ${data.totals.themes} themes` : ""}
</span>
)}
</div>
<button onClick={() => void loadPlugins()} disabled={loading || busyKey !== null} style={buttonStyle(loading || busyKey !== null)}>
{t("i18n.refresh")}
</button>
<button onClick={onClose} style={buttonStyle(false)}>
{t("i18n.close")}
</button>
</div>
</div>
</div>
);
}