feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+993
View File
@@ -0,0 +1,993 @@
"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>
);
}