mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
994 lines
33 KiB
TypeScript
994 lines
33 KiB
TypeScript
"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>
|
||
);
|
||
}
|