diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx
index 55a8ac83..26e46f79 100644
--- a/integrations/dsh/src/client/index.tsx
+++ b/integrations/dsh/src/client/index.tsx
@@ -9,6 +9,7 @@ import {
envZh,
type EnvTranslator,
} from "../environment/client/tab.js";
+import { environmentStyles } from "../environment/client/styles.js";
import {
ReMeStatusPage,
statusEn,
@@ -343,25 +344,7 @@ function ReMeSettingsCard({
(value !== undefined &&
draft !== undefined &&
!sameDraft(draft, draftFrom(value)));
- const validation =
- draft === undefined
- ? undefined
- : parseDraft(
- draft,
- value && isRecord(value)
- ? {
- authToken:
- typeof value.authToken === "string" ? value.authToken : "",
- pythonPath:
- typeof value.pythonPath === "string" ? value.pythonPath : "",
- remeWorkspaceDir:
- typeof value.remeWorkspaceDir === "string"
- ? value.remeWorkspaceDir
- : "",
- autoStart: value.autoStart === true,
- }
- : undefined,
- );
+ const validation = draft === undefined ? undefined : parseDraft(draft);
const update = (field: K, next: Draft[K]) => {
if (value === undefined) return;
@@ -758,13 +741,12 @@ function draftFrom(value: ReMeSettings): Draft {
};
}
-function parseDraft(
- draft: Draft,
- passthrough?: Pick<
- ReMeSettings,
- "authToken" | "pythonPath" | "remeWorkspaceDir" | "autoStart"
- >,
-): ReMeSettings | undefined {
+/**
+ * Validate the memory-settings drafts. Only the fields this page's `FIELDS`
+ * list writes are parsed: the interpreter, workspace and auto-start values
+ * belong to the Environment tab, which writes them on its own.
+ */
+function parseDraft(draft: Draft): Pick | undefined {
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
const backgroundTimeoutMs = boundedInteger(
draft.backgroundTimeoutMs,
@@ -805,10 +787,6 @@ function parseDraft(
const [hour, minute] = draft.dreamTime.split(":");
return {
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
- authToken: passthrough?.authToken ?? "",
- pythonPath: passthrough?.pythonPath ?? "",
- remeWorkspaceDir: passthrough?.remeWorkspaceDir ?? "",
- autoStart: passthrough?.autoStart ?? false,
requestTimeoutMs: requestTimeoutMs!,
backgroundTimeoutMs: backgroundTimeoutMs!,
shutdownTimeoutMs: shutdownTimeoutMs!,
@@ -871,7 +849,7 @@ function isRecord(value: unknown): value is Record {
function installStyles(): () => void {
const tag = document.createElement("style");
tag.dataset.pluginCss = "@agentscope-ai/reme-dsh-plugin/settings";
- tag.textContent = styles;
+ tag.textContent = `${styles}\n${environmentStyles}`;
document.head.appendChild(tag);
return () => tag.remove();
}
diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts
index 1801be46..130d7fb3 100644
--- a/integrations/dsh/src/client/styles.ts
+++ b/integrations/dsh/src/client/styles.ts
@@ -37,86 +37,5 @@ export const styles = `
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
-.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
-.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
-.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
-.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
-.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
-.reme-env-hero-copy{min-width:0}
-.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
-.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
-.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
-.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
-.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
-.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
-.reme-env-hero-error,.reme-env-notice{margin:0}
-.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
-.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
-.reme-env-card.collapsed>header{padding-bottom:14px}
-.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
-.reme-env-card>header>div{min-width:0;flex:1}
-.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
-.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
-.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
-.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
-.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
-.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
-.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
-.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
-.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
-.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
-.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
-.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
-.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
-.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
-.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
-.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
-.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
-.reme-env-note{margin:0}
-.reme-env-picker{display:flex;flex-direction:column;gap:9px}
-.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
-.reme-env-picker-bar .reme-settings-muted{margin:0}
-.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
-.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
-.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
-.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
-.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
-.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
-.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
-.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
-.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
-.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
-.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
-.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
-.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
-.reme-env-factrow{display:flex;align-items:center;gap:9px}
-.reme-env-factrow .reme-env-path{flex:1}
-.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
-.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
-.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
-.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
-.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
-.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
-.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
-.reme-env-metric .reme-status-badge i{margin-top:5px}
-.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
-.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
-.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
-.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
-.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
-.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
-.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
-.reme-env-progress{display:flex;flex-direction:column;gap:8px}
-.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
-.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
-.reme-env-result p{margin:0}
-.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
-.reme-env-inline{display:flex;align-items:center;gap:8px}
-.reme-env-inline input{flex:1;min-width:0}
-.reme-env-config{padding:2px 0 0}
-.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
-.reme-env .reme-settings-details{margin:0}
-.reme-env .reme-settings-details pre{margin:8px 0 0}
-@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
`;
diff --git a/integrations/dsh/src/environment/client/dependency.tsx b/integrations/dsh/src/environment/client/dependency.tsx
new file mode 100644
index 00000000..25c15295
--- /dev/null
+++ b/integrations/dsh/src/environment/client/dependency.tsx
@@ -0,0 +1,465 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { StatusRpc } from "../../client/status-page.js";
+import { safeError } from "../../endpoint.js";
+import type { InstallStatus, RemeCheck } from "../types.js";
+import { fill, type EnvTranslator } from "./locales.js";
+import { StatusBadge } from "./presentation.js";
+import { envCall, parseInstallStatus, parseRemeCheck } from "./rpc.js";
+
+export interface CheckState {
+ phase: "idle" | "ready" | "error";
+ data?: RemeCheck;
+ error?: string;
+ /** The interpreter this result belongs to. */
+ forPath: string;
+}
+
+export interface InstallState {
+ phase: "idle" | "running" | "done";
+ /** Job this section polls; another installation cannot replace it. */
+ jobId?: string;
+ job?: InstallStatus;
+ error?: string;
+}
+
+export interface DependencyState {
+ /** Check result, only when it belongs to the interpreter on screen. */
+ checkData: RemeCheck | undefined;
+ checkError: string | undefined;
+ checking: boolean;
+ install: InstallState;
+ /** Wall clock tick while an installation runs, for the elapsed label. */
+ now: number;
+ recheck(): void;
+ start(upgrade: boolean): Promise;
+ cancel(): Promise;
+ dismiss(): void;
+}
+
+/**
+ * The dependency section's asynchronous state: one reme-ai check per
+ * interpreter, and one installation job bound to its own id.
+ */
+export function useDependency(
+ rpc: StatusRpc,
+ options: { pythonPath: string; onInstalled(): void },
+): DependencyState {
+ const { pythonPath, onInstalled } = options;
+ const [check, setCheck] = useState({
+ phase: "idle",
+ forPath: "",
+ });
+ const [install, setInstall] = useState({ phase: "idle" });
+ const [tick, setTick] = useState(0);
+ const [now, setNow] = useState(0);
+ const installed = useRef(onInstalled);
+ installed.current = onInstalled;
+
+ useEffect(() => {
+ if (!pythonPath) return;
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const value = await envCall(
+ rpc,
+ "checkReme",
+ { pythonPath },
+ controller.signal,
+ );
+ setCheck({
+ phase: "ready",
+ data: parseRemeCheck(value),
+ error: undefined,
+ forPath: pythonPath,
+ });
+ } catch (error) {
+ if (!controller.signal.aborted)
+ setCheck({
+ phase: "error",
+ error: safeError(error),
+ forPath: pythonPath,
+ });
+ }
+ })();
+ return () => controller.abort();
+ }, [rpc, pythonPath, tick]);
+
+ // Restoring a running job is per interpreter: a job started for another
+ // interpreter belongs to that interpreter's section, not this one.
+ useEffect(() => {
+ if (!pythonPath) return;
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const status = parseInstallStatus(
+ await envCall(rpc, "installStatus", {}, controller.signal),
+ );
+ if (status?.phase !== "running" || status.pythonPath !== pythonPath)
+ return;
+ setInstall({ phase: "running", jobId: status.id, job: status });
+ } catch {
+ // Restoring is best-effort; a later action reports real failures.
+ }
+ })();
+ return () => controller.abort();
+ }, [rpc, pythonPath]);
+
+ const jobId = install.jobId;
+ useEffect(() => {
+ if (install.phase !== "running") return;
+ const controller = new AbortController();
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+ const poll = async (): Promise => {
+ try {
+ const status = parseInstallStatus(
+ await envCall(
+ rpc,
+ "installStatus",
+ { jobId: jobId ?? "" },
+ controller.signal,
+ ),
+ );
+ if (cancelled) return;
+ if (!status) {
+ setInstall({ phase: "idle" });
+ return;
+ }
+ if (status.phase === "running") {
+ setInstall((current) => ({ ...current, job: status }));
+ timer = setTimeout(() => void poll(), 2000);
+ return;
+ }
+ setInstall((current) => ({ ...current, phase: "done", job: status }));
+ if (status.phase === "succeeded") installed.current();
+ } catch (error) {
+ if (cancelled) return;
+ setInstall((current) => ({
+ ...current,
+ phase: "done",
+ error: safeError(error),
+ }));
+ }
+ };
+ void poll();
+ return () => {
+ cancelled = true;
+ controller.abort();
+ if (timer) clearTimeout(timer);
+ };
+ }, [install.phase, jobId, rpc]);
+
+ useEffect(() => {
+ if (install.phase !== "running") return;
+ const timer = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, [install.phase]);
+
+ const recheck = useCallback(() => {
+ setCheck({ phase: "idle", forPath: "" });
+ setTick((current) => current + 1);
+ }, []);
+
+ const start = useCallback(
+ async (upgrade: boolean) => {
+ if (!pythonPath || install.phase === "running") return;
+ setInstall({ phase: "idle" });
+ try {
+ const status = parseInstallStatus(
+ await envCall(rpc, "install", { pythonPath, upgrade }),
+ );
+ setInstall(
+ status && status.phase === "running"
+ ? { phase: "running", jobId: status.id, job: status }
+ : status
+ ? { phase: "done", jobId: status.id, job: status }
+ : { phase: "idle" },
+ );
+ } catch (error) {
+ setInstall({ phase: "done", error: safeError(error) });
+ }
+ },
+ [install.phase, pythonPath, rpc],
+ );
+
+ const cancel = useCallback(async () => {
+ if (!jobId) return;
+ try {
+ await envCall(rpc, "installCancel", { jobId });
+ } catch {
+ // Polling reflects the final phase either way.
+ }
+ }, [jobId, rpc]);
+
+ const dismiss = useCallback(() => setInstall({ phase: "idle" }), []);
+
+ const current = check.forPath === pythonPath ? check : undefined;
+ return {
+ checkData: current?.data,
+ checkError: current?.error,
+ checking: Boolean(pythonPath) && check.forPath !== pythonPath,
+ install,
+ now,
+ recheck,
+ start,
+ cancel,
+ dismiss,
+ };
+}
+
+function elapsedText(job: InstallStatus, now: number): string {
+ const started = Date.parse(job.startedAt);
+ if (!Number.isFinite(started) || now < started) return "";
+ const seconds = Math.floor((now - started) / 1000);
+ const minutes = Math.floor(seconds / 60);
+ return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
+}
+
+/** Step 2: reme-ai in the selected interpreter, and its one-click install. */
+export function DependencySection({
+ t,
+ pythonPath,
+ dependency,
+}: {
+ t: EnvTranslator;
+ pythonPath: string;
+ dependency: DependencyState;
+}): JSX.Element {
+ const { checkData, checkError, checking, install, now } = dependency;
+ const [confirming, setConfirming] = useState(false);
+ const [mirror, setMirror] = useState("");
+
+ const badge = !checkData
+ ? undefined
+ : !checkData.installed
+ ? { tone: "progress" as const, text: t("depMissing") }
+ : checkData.versionOk && checkData.hasCore
+ ? { tone: "ok" as const, text: t("depReady") }
+ : { tone: "progress" as const, text: t("depUpgrade") };
+
+ return (
+
+
+
+ 2
+
+
+
{t("dependencyTitle")}
+
{t("dependencyHint")}
+
+ {badge ? (
+
+ ) : null}
+
+
+
+ {t("interpreterLabel")}
+
+ {pythonPath || t("noInterpreter")}
+
+
+
+ {!pythonPath ? (
+
{t("selectInterpreterFirst")}
+ ) : checking ? (
+
{t("checkingReme")}
+ ) : null}
+ {checkError ? (
+
+ {t("checkRemeFailed")}
+ {checkError ? `: ${checkError}` : ""}
+
+ ) : null}
+ {checkData ? (
+
+
+
{t("remeLabel")}
+
+ {checkData.installed ? (
+ <>
+ {`reme-ai ${checkData.version}`}
+ {checkData.versionOk ? null : (
+ {t("upgradeRecommended")}
+ )}
+ >
+ ) : (
+ {t("notInstalled")}
+ )}
+
+
+
+
{t("coreLabel")}
+
+ {checkData.installed ? (
+
+ {checkData.hasCore ? t("coreOk") : t("coreMissing")}
+
+ ) : (
+ —
+ )}
+
+
+
+
{t("cliLabel")}
+
+ {checkData.installed ? (
+ checkData.cliPath ? (
+
+ {checkData.cliPath}
+
+ ) : (
+
+ {t("cliFallback")}
+
+ )
+ ) : (
+ —
+ )}
+
+
+
+ ) : null}
+ {checkData && install.phase !== "running" ? (
+
+
+ {confirming ? (
+
+
{fill(t("installConfirm"), { path: pythonPath })}
+
+
+
+ ) : checkData.installed && checkData.versionOk ? (
+
+ ) : checkData.installed ? (
+
+ ) : (
+
+ )}
+
+ {t("condaHint")}
+
+
+ ) : null}
+ {install.phase === "running" && install.job ? (
+
+
+
+ {t("installing")}
+ {elapsedText(install.job, now)
+ ? ` · ${elapsedText(install.job, now)}`
+ : ""}
+
+
+
+
+ {install.job.logTail || t("waitingForOutput")}
+
+
+ ) : null}
+ {install.phase === "done" ? (
+
+ {install.job?.phase === "succeeded" ? (
+
{t("installSucceeded")}
+ ) : null}
+ {install.job?.phase === "cancelled" ? (
+
{t("installCancelled")}
+ ) : null}
+ {install.job?.phase === "failed" ? (
+ <>
+
{t("installFailed")}
+ {install.job.logTail ? (
+
{install.job.logTail}
+ ) : null}
+
{install.job.logPath}
+ >
+ ) : null}
+ {install.error ? (
+
{install.error}
+ ) : null}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/integrations/dsh/src/environment/client/locales.ts b/integrations/dsh/src/environment/client/locales.ts
new file mode 100644
index 00000000..1c777f10
--- /dev/null
+++ b/integrations/dsh/src/environment/client/locales.ts
@@ -0,0 +1,312 @@
+/** Copy for the Environment tab, registered under `reme.environment`. */
+export const envEn = {
+ connectionTitle: "Service connection",
+ endpointLabel: "Service URL",
+ checking: "Checking…",
+ connected: "Connected",
+ disconnected: "Not connected",
+ retest: "Test connection",
+ remoteNotice:
+ "This service URL is not local, so local environment management is disabled.",
+ pythonTitle: "Local Python",
+ pythonHint:
+ "ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
+ detecting: "Detecting Python environments…",
+ detectFailed: "Detection failed",
+ refresh: "Refresh",
+ emptyTitle: "No usable Python found",
+ emptyHint:
+ "Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
+ miniconda: "Miniconda download",
+ recommended: "recommended",
+ okBadge: "OK",
+ tooOld: "Needs 3.11+",
+ originConda: "Conda ({name})",
+ originPythonOrg: "python.org installer",
+ originStore: "Microsoft Store",
+ originVenv: "venv environment",
+ originSystem: "System PATH",
+ originUnknown: "Unknown origin",
+ errorsTitle: "Probe details",
+ dependencyTitle: "ReMe dependency",
+ dependencyHint:
+ "The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
+ interpreterLabel: "Interpreter",
+ noInterpreter: "No interpreter selected",
+ selectInterpreterFirst: "Select or install a Python interpreter above first.",
+ recheck: "Check",
+ checkingReme: "Checking ReMe installation…",
+ checkRemeFailed: "ReMe check failed",
+ remeLabel: "reme-ai",
+ coreLabel: "[core] extras",
+ cliLabel: "reme CLI",
+ notInstalled: "Not installed",
+ upgradeRecommended: "upgrade recommended",
+ coreOk: "Available",
+ coreMissing: "Missing (auto_memory / auto_dream unavailable)",
+ cliFallback: "python wrapper fallback",
+ installButton: "Install reme-ai[core]",
+ upgradeButton: "Upgrade reme-ai",
+ reinstall: "Reinstall reme-ai[core]",
+ installConfirm:
+ "Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
+ confirmInstall: "Confirm install",
+ cancel: "Cancel",
+ installing: "Installing",
+ cancelInstall: "Cancel install",
+ cancelling: "Cancelling…",
+ waitingForOutput: "Waiting for pip output…",
+ installSucceeded:
+ "ReMe installed. Search, automatic memory and memory consolidation are now available.",
+ installFailed: "Installation failed. See the log below.",
+ installCancelled: "Installation cancelled.",
+ dismiss: "Dismiss",
+ mirrorLabel: "Package index",
+ mirrorDefault: "Default (pip config)",
+ mirrorPypi: "PyPI",
+ mirrorTsinghua: "Tsinghua",
+ mirrorAliyun: "Aliyun",
+ condaHint:
+ "In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
+ configHint:
+ "ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
+ workspaceDir: "Workspace directory",
+ workspaceDirHint:
+ "Empty means ~/.reme. Restart the service after changing it.",
+ saveWorkspace: "Save path",
+ envLoading: "Loading .env…",
+ envMissing: "No .env yet; saving creates it.",
+ envPath: "File",
+ llmGroup: "LLM (auto memory / consolidation)",
+ embeddingGroup: "Embedding",
+ backendLabel: "Backend",
+ modelLabel: "Model",
+ apiKeyLabel: "API key",
+ baseUrlLabel: "Base URL",
+ secretSet: "Set {masked}; leave blank to keep",
+ secretUnset: "Not set",
+ embeddingHint:
+ "ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
+ saveEnv: "Save to .env",
+ savingEnv: "Saving…",
+ savedEnv: "Saved to {path}",
+ saveEnvFailed: "Saving failed",
+ testLlmButton: "Test LLM connection",
+ testingLlm: "Testing…",
+ llmOk: "LLM reachable ({ms} ms)",
+ llmFailed: "LLM test failed",
+ serviceHint:
+ "Start or stop the local ReMe service. The service keeps running after DSH closes.",
+ serviceBadgeStarting: "Starting…",
+ serviceBadgeConflict: "Port occupied",
+ serviceBadgeStopped: "Stopped",
+ serviceRunning: "Running",
+ startService: "Start service",
+ stopService: "Stop service",
+ startTimeout: "The service did not become healthy within 90 seconds",
+ stopConfirm:
+ "Stop the ReMe service? Automatic memory in flight may need a retry.",
+ foreignNote:
+ "This service was started outside DSH; its state is shown but it is not managed here.",
+ conflictNote:
+ "Another program occupies this port. Change the service URL port in Memory settings.",
+ viewLogs: "View log",
+ logEmpty: "The service log is empty.",
+ autoStartLabel: "Start the service automatically with DSH",
+ managedNote: "Managed by this plugin · PID {pid}",
+ serviceError: "Service operation failed",
+ configTitle: "ReMe parameters",
+ controlTitle: "Service control",
+ heroEyebrow: "ReMe local runtime",
+ heroReady: "Ready — memory runs locally",
+ heroSetup: "Finish the setup in the steps below",
+ heroService: "Service",
+ heroPython: "Python",
+ heroReme: "ReMe",
+ noneSelected: "not selected",
+ noneDetected: "not detected",
+ envCount: "{count} environments",
+ envRemeCount: "{count} with ReMe",
+ envOthers: "{count} other environments",
+ scannedAt: "Detected at {time}",
+ currentInterpreter: "Current interpreter",
+ notInDetection: "not detected",
+ autoSelected:
+ "The recommended interpreter is used until you pick another one.",
+ savingSelection: "Saving…",
+ changeInterpreter: "Change interpreter",
+ collapseList: "Collapse list",
+ searchPlaceholder: "Filter by version, origin or path",
+ noMatch: "No environment matches the filter.",
+ showAllEnvs: "Show all {count}",
+ showFewer: "Show fewer",
+ depReady: "Ready",
+ depUpgrade: "Upgrade available",
+ depMissing: "Not installed",
+ expand: "Expand",
+ collapse: "Collapse",
+ recommendWhyReme:
+ "Already has a supported reme-ai, and it is a dedicated conda environment.",
+ recommendWhyRemeAny: "Already has a supported reme-ai.",
+ recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.",
+ recommendWhyFallback: "Fallback: the first usable interpreter.",
+};
+
+export const envZh: typeof envEn = {
+ connectionTitle: "服务连接",
+ endpointLabel: "服务地址",
+ checking: "正在检查…",
+ connected: "已连接",
+ disconnected: "未连接",
+ retest: "测试连接",
+ remoteNotice: "当前服务地址不是本机地址,本地环境管理已停用。",
+ pythonTitle: "本地 Python 环境",
+ pythonHint:
+ "ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
+ detecting: "正在探测本地 Python 环境…",
+ detectFailed: "探测失败",
+ refresh: "重新探测",
+ emptyTitle: "未检测到可用的 Python 环境",
+ emptyHint:
+ "可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
+ miniconda: "下载 Miniconda",
+ recommended: "推荐",
+ okBadge: "可用",
+ tooOld: "需要 3.11+",
+ originConda: "Conda({name})",
+ originPythonOrg: "python.org 安装器",
+ originStore: "Microsoft Store",
+ originVenv: "venv 虚拟环境",
+ originSystem: "系统 PATH",
+ originUnknown: "来源未知",
+ errorsTitle: "探测细节",
+ dependencyTitle: "ReMe 依赖",
+ dependencyHint:
+ "本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
+ interpreterLabel: "解释器",
+ noInterpreter: "尚未选择解释器",
+ selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
+ recheck: "检测",
+ checkingReme: "正在检测 ReMe 安装…",
+ checkRemeFailed: "ReMe 检测失败",
+ remeLabel: "reme-ai",
+ coreLabel: "[core] 依赖",
+ cliLabel: "reme CLI",
+ notInstalled: "未安装",
+ upgradeRecommended: "建议升级",
+ coreOk: "可用",
+ coreMissing: "缺失(auto_memory / auto_dream 不可用)",
+ cliFallback: "回退到 python 包装",
+ installButton: "安装 reme-ai[core]",
+ upgradeButton: "升级 reme-ai",
+ reinstall: "重装 reme-ai[core]",
+ installConfirm:
+ "将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
+ confirmInstall: "确认安装",
+ cancel: "取消",
+ installing: "正在安装",
+ cancelInstall: "取消安装",
+ cancelling: "正在取消…",
+ waitingForOutput: "等待 pip 输出…",
+ installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
+ installFailed: "安装失败,请查看下方日志。",
+ installCancelled: "安装已取消。",
+ dismiss: "关闭",
+ mirrorLabel: "软件源",
+ mirrorDefault: "默认(pip 配置)",
+ mirrorPypi: "PyPI 官方",
+ mirrorTsinghua: "清华镜像",
+ mirrorAliyun: "阿里云镜像",
+ condaHint:
+ "conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
+ configHint:
+ "ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
+ workspaceDir: "Workspace 目录",
+ workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
+ saveWorkspace: "保存路径",
+ envLoading: "正在读取 .env…",
+ envMissing: "尚未创建 .env;保存后自动创建。",
+ envPath: "文件",
+ llmGroup: "LLM(自动记忆 / 记忆整理)",
+ embeddingGroup: "Embedding",
+ backendLabel: "后端",
+ modelLabel: "模型",
+ apiKeyLabel: "API Key",
+ baseUrlLabel: "Base URL",
+ secretSet: "已设置 {masked};留空保持不变",
+ secretUnset: "未设置",
+ embeddingHint:
+ "ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
+ saveEnv: "保存到 .env",
+ savingEnv: "正在保存…",
+ savedEnv: "已保存到 {path}",
+ saveEnvFailed: "保存失败",
+ testLlmButton: "测试 LLM 连接",
+ testingLlm: "正在测试…",
+ llmOk: "LLM 连接正常({ms} ms)",
+ llmFailed: "LLM 测试失败",
+ serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
+ serviceBadgeStarting: "启动中…",
+ serviceBadgeConflict: "端口被占用",
+ serviceBadgeStopped: "已停止",
+ serviceRunning: "运行中",
+ startService: "启动服务",
+ stopService: "停止服务",
+ startTimeout: "服务在 90 秒内未就绪",
+ stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
+ foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
+ conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
+ viewLogs: "查看日志",
+ logEmpty: "服务日志为空。",
+ autoStartLabel: "随 DSH 启动时自动拉起服务",
+ managedNote: "由本插件托管 · PID {pid}",
+ serviceError: "服务操作失败",
+ configTitle: "ReMe 参数",
+ controlTitle: "服务控制",
+ heroEyebrow: "ReMe 本地运行时",
+ heroReady: "已就绪,记忆保存在本地",
+ heroSetup: "按下方步骤完成配置",
+ heroService: "服务",
+ heroPython: "Python",
+ heroReme: "ReMe",
+ noneSelected: "未选择",
+ noneDetected: "未探测",
+ envCount: "{count} 个环境",
+ envRemeCount: "{count} 个已装 ReMe",
+ envOthers: "另有 {count} 个环境",
+ scannedAt: "探测于 {time}",
+ currentInterpreter: "当前解释器",
+ notInDetection: "不在探测结果中",
+ autoSelected: "当前使用推荐解释器;选择其他解释器后会写入配置。",
+ savingSelection: "正在保存…",
+ changeInterpreter: "更换解释器",
+ collapseList: "收起列表",
+ searchPlaceholder: "按版本、来源或路径筛选",
+ noMatch: "没有匹配的环境。",
+ showAllEnvs: "显示全部 {count} 个",
+ showFewer: "收起多余项",
+ depReady: "就绪",
+ depUpgrade: "可升级",
+ depMissing: "未安装",
+ expand: "展开",
+ collapse: "收起",
+ recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。",
+ recommendWhyRemeAny: "已安装可用的 reme-ai。",
+ recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。",
+ recommendWhyFallback: "兜底选择:第一个可用的解释器。",
+};
+
+export type EnvTranslator = (key: keyof typeof envEn) => string;
+
+/** One translated string key of this tab. */
+export type EnvKey = keyof typeof envEn;
+
+/** Fill `{placeholder}` tokens in a translated string. */
+export function fill(
+ text: string,
+ values: Record,
+): string {
+ return text.replace(/\{(\w+)\}/g, (match, name: string) =>
+ name in values ? String(values[name]) : match,
+ );
+}
diff --git a/integrations/dsh/src/environment/client/presentation.tsx b/integrations/dsh/src/environment/client/presentation.tsx
new file mode 100644
index 00000000..e818aaa0
--- /dev/null
+++ b/integrations/dsh/src/environment/client/presentation.tsx
@@ -0,0 +1,118 @@
+import type { ReactNode } from "react";
+
+import type { PythonEnv, PythonOrigin } from "../types.js";
+import type { EnvTranslator } from "./locales.js";
+
+/** Tone of a status chip: neutral, healthy, broken, or in progress. */
+export type BadgeTone = "" | "ok" | "bad" | "progress";
+
+export interface Badge {
+ tone: BadgeTone;
+ text: string;
+}
+
+/** One dot-plus-text chip, shared by the hero, the cards and the metrics. */
+export function StatusBadge({
+ tone,
+ children,
+}: {
+ tone: BadgeTone;
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+
+ {children}
+
+ );
+}
+
+/** One pill used for origins, versions and capability markers. */
+export function Tag({
+ tone = "",
+ title,
+ children,
+}: {
+ tone?: "" | "ok" | "bad" | "warn" | "brand";
+ title?: string;
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
+ switch (origin.kind) {
+ case "conda":
+ return t("originConda").replace("{name}", origin.envName);
+ case "python.org":
+ return t("originPythonOrg");
+ case "windows-store":
+ return t("originStore");
+ case "venv":
+ return t("originVenv");
+ case "system":
+ return t("originSystem");
+ default:
+ return t("originUnknown");
+ }
+}
+
+/** Human-readable reason behind the "recommended" marker. */
+export function recommendReason(t: EnvTranslator, env: PythonEnv): string {
+ const named = env.origin.kind === "conda" && env.origin.envName !== "base";
+ if (env.reme?.installed)
+ return t(named ? "recommendWhyReme" : "recommendWhyRemeAny");
+ return t(named ? "recommendWhyNamed" : "recommendWhyFallback");
+}
+
+/** The reme-ai, recommendation and version pills one interpreter carries. */
+export function InterpreterBadges({
+ t,
+ env,
+ suggested,
+}: {
+ t: EnvTranslator;
+ env: PythonEnv;
+ suggested: string;
+}): JSX.Element {
+ const outdated = env.reme?.versionOk === false;
+ return (
+ <>
+ {env.reme?.installed ? (
+ {`ReMe ${env.reme.version}`}
+ ) : null}
+ {env.exe === suggested ? (
+
+ {t("recommended")}
+
+ ) : null}
+ {env.ok ? null : {t("tooOld")}}
+ >
+ );
+}
+
+/** Label, control and hint of one settings field. */
+export function EnvField({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+
+ {children}
+ {hint ? {hint} : null}
+
+ );
+}
diff --git a/integrations/dsh/src/environment/client/python.tsx b/integrations/dsh/src/environment/client/python.tsx
new file mode 100644
index 00000000..540c4319
--- /dev/null
+++ b/integrations/dsh/src/environment/client/python.tsx
@@ -0,0 +1,315 @@
+import { useCallback, useEffect, useState } from "react";
+import type { StatusRpc } from "../../client/status-page.js";
+import { safeError } from "../../endpoint.js";
+import type { PythonDetection, PythonEnv } from "../types.js";
+import { fill, type EnvTranslator } from "./locales.js";
+import { InterpreterBadges, originLabel } from "./presentation.js";
+import { envCall, parseDetection } from "./rpc.js";
+
+const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
+
+/** Interpreter rows shown before the list asks to expand. */
+const ENV_PAGE = 6;
+
+export interface DetectionState {
+ phase: "loading" | "ready" | "error";
+ data?: PythonDetection;
+ error?: string;
+}
+
+export interface PythonSelection {
+ detection: DetectionState;
+ /** Interpreter the Host recommends for this machine. */
+ suggested: string;
+ /** Selected interpreter from settings, or the recommendation when unset. */
+ selectedPath: string;
+ selectedEnv: PythonEnv | undefined;
+ /** Interpreter path whose selection is being written. */
+ saving: string;
+ /** Probe again; the Host caches detection for a minute. */
+ refresh(): void;
+ select(env: PythonEnv): void;
+}
+
+/**
+ * The interpreter section's asynchronous state: one detection probe per
+ * refresh, plus the selection write that settings owns.
+ */
+export function usePython(
+ rpc: StatusRpc,
+ options: {
+ pythonPath: string;
+ writable: boolean;
+ onSelect(exe: string): Promise;
+ },
+): PythonSelection {
+ const { pythonPath, writable, onSelect } = options;
+ const [detection, setDetection] = useState({
+ phase: "loading",
+ });
+ const [tick, setTick] = useState(0);
+ const [saving, setSaving] = useState("");
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const value = await envCall(
+ rpc,
+ "python",
+ { refresh: tick > 0 },
+ controller.signal,
+ );
+ setDetection({ phase: "ready", data: parseDetection(value) });
+ } catch (error) {
+ if (!controller.signal.aborted)
+ setDetection({ phase: "error", error: safeError(error) });
+ }
+ })();
+ return () => controller.abort();
+ }, [rpc, tick]);
+
+ const detected = detection.phase === "ready" ? detection.data : undefined;
+ const suggested = detected?.suggested ?? "";
+ const selectedPath = pythonPath || suggested;
+
+ const refresh = useCallback(() => {
+ setDetection({ phase: "loading" });
+ setTick((current) => current + 1);
+ }, []);
+
+ const select = useCallback(
+ (env: PythonEnv) => {
+ if (!writable || saving) return;
+ setSaving(env.exe);
+ void onSelect(env.exe)
+ .catch(() => undefined)
+ .finally(() => setSaving(""));
+ },
+ [onSelect, saving, writable],
+ );
+
+ return {
+ detection,
+ suggested,
+ selectedPath,
+ selectedEnv: detected?.envs.find((env) => env.exe === selectedPath),
+ saving,
+ refresh,
+ select,
+ };
+}
+
+/** Step 1: the interpreter a local service runs on. */
+export function PythonSection({
+ t,
+ selection,
+ writable,
+}: {
+ t: EnvTranslator;
+ selection: PythonSelection;
+ writable: boolean;
+}): JSX.Element {
+ const { detection, suggested, selectedPath, saving } = selection;
+ const detected = detection.phase === "ready" ? detection.data : undefined;
+ const envs = detected?.envs ?? [];
+ const [listOpen, setListOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [showAll, setShowAll] = useState(false);
+
+ const selectedEnv = selection.selectedEnv;
+ const others = envs.filter((env) => env.exe !== selectedPath);
+ const filter = query.trim().toLowerCase();
+ const matches = filter
+ ? others.filter((env) =>
+ `${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
+ .toLowerCase()
+ .includes(filter),
+ )
+ : others;
+ const visible = showAll ? matches : matches.slice(0, ENV_PAGE);
+ const hidden = matches.length - visible.length;
+ const withReme = envs.filter((env) => env.reme?.installed === true).length;
+
+ return (
+
+
+
+ 1
+
+
+
{t("pythonTitle")}
+
{t("pythonHint")}
+
+ {envs.length > 0 ? (
+
+ ) : null}
+
+
+ {detection.phase === "loading" ? (
+
{t("detecting")}
+ ) : null}
+ {detection.phase === "error" ? (
+
+ {t("detectFailed")}
+ {detection.error ? `: ${detection.error}` : ""}
+
+ ) : null}
+ {detection.phase === "error" || envs.length === 0 ? (
+
+ ) : null}
+ {selectedEnv ? (
+
+
+
+
+ {`Python ${selectedEnv.version || "?"}`}
+
+ {originLabel(t, selectedEnv.origin)}
+
+
+
+
+ {selectedEnv.exe}
+
+
+ {saving === selectedEnv.exe ? (
+
+ {t("savingSelection")}
+
+ ) : null}
+
+ ) : selectedPath ? (
+
+
+
+
+ {t("currentInterpreter")}
+ {t("notInDetection")}
+
+
+ {selectedPath}
+
+
+
+ ) : null}
+ {!selectedPath && suggested ? (
+
+ {t("autoSelected")}
+
+ ) : null}
+ {others.length > 0 ? (
+
+
+
+ {listOpen ? (
+ {
+ setQuery(event.target.value);
+ setShowAll(false);
+ }}
+ />
+ ) : (
+
+ {fill(t("envOthers"), { count: others.length })}
+
+ )}
+
+ {listOpen ? (
+ <>
+ {visible.length > 0 ? (
+
+ {visible.map((env) => (
+
+ ))}
+
+ ) : (
+
{t("noMatch")}
+ )}
+ {hidden > 0 ? (
+
+ ) : matches.length > ENV_PAGE ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ ) : null}
+ {detected && detected.errors.length > 0 ? (
+
+ {t("errorsTitle")}
+ {detected.errors.join("\n")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/integrations/dsh/src/environment/client/rpc.ts b/integrations/dsh/src/environment/client/rpc.ts
new file mode 100644
index 00000000..fd1f233d
--- /dev/null
+++ b/integrations/dsh/src/environment/client/rpc.ts
@@ -0,0 +1,105 @@
+import type { StatusRpc } from "../../client/status-page.js";
+import type {
+ EnvSummary,
+ InstallStatus,
+ LlmTestResult,
+ PythonDetection,
+ RemeCheck,
+ ServiceLog,
+ ServiceStartResult,
+ ServiceState,
+} from "../types.js";
+
+/** Methods the Host exposes on the `remeEnv` wire namespace. */
+export type EnvMethod =
+ | "python"
+ | "checkReme"
+ | "install"
+ | "installStatus"
+ | "installCancel"
+ | "readEnv"
+ | "writeEnv"
+ | "testLlm"
+ | "serviceStatus"
+ | "serviceStart"
+ | "serviceStop"
+ | "serviceLog";
+
+/**
+ * Call one environment method. DSH exposes the SRC gateway with a single plain
+ * `input` parameter, so every call sends `{ args: { input } }`.
+ */
+export async function envCall(
+ rpc: StatusRpc,
+ method: EnvMethod,
+ input: Record,
+ signal?: AbortSignal,
+): Promise {
+ const result = await rpc.call(
+ "/api",
+ `remeEnv/${method}`,
+ { args: { input } },
+ signal,
+ );
+ if (!result.ok) throw new Error(result.error.message || result.error.code);
+ return result.value;
+}
+
+function record(value: unknown, what: string): Record {
+ if (typeof value !== "object" || value === null)
+ throw new Error(`Invalid ${what} response`);
+ return value as Record;
+}
+
+/** Guard a detection payload: the interpreter list is what the tab needs. */
+export function parseDetection(value: unknown): PythonDetection {
+ const body = record(value, "environment detection");
+ if (!Array.isArray(body.envs))
+ throw new Error("Invalid environment detection response");
+ return value as PythonDetection;
+}
+
+export function parseRemeCheck(value: unknown): RemeCheck {
+ return record(value, "ReMe check") as unknown as RemeCheck;
+}
+
+/** `null` is the valid "no job yet" answer, so it is not an error. */
+export function parseInstallStatus(value: unknown): InstallStatus | null {
+ if (typeof value !== "object" || value === null) return null;
+ const body = value as Record;
+ return typeof body.id === "string" && typeof body.phase === "string"
+ ? (value as unknown as InstallStatus)
+ : null;
+}
+
+export function parseEnvSummary(value: unknown): EnvSummary {
+ const body = record(value, ".env summary");
+ if (!Array.isArray(body.entries))
+ throw new Error("Invalid .env summary response");
+ return value as unknown as EnvSummary;
+}
+
+export function parseLlmResult(value: unknown): LlmTestResult {
+ return record(value, "LLM test") as unknown as LlmTestResult;
+}
+
+export function parseServiceState(value: unknown): ServiceState {
+ const body = record(value, "service status");
+ if (typeof body.phase !== "string")
+ throw new Error("Invalid service status response");
+ return value as unknown as ServiceState;
+}
+
+export function parseServiceStart(value: unknown): ServiceStartResult {
+ const body = record(value, "service start");
+ if (typeof body.started !== "boolean")
+ throw new Error("Invalid service start response");
+ return value as unknown as ServiceStartResult;
+}
+
+export function parseServiceLog(value: unknown): ServiceLog {
+ const body = record(value, "service log");
+ if (typeof body.lines !== "string")
+ throw new Error("Invalid service log response");
+ return value as unknown as ServiceLog;
+}
diff --git a/integrations/dsh/src/environment/client/service.tsx b/integrations/dsh/src/environment/client/service.tsx
new file mode 100644
index 00000000..854f3a32
--- /dev/null
+++ b/integrations/dsh/src/environment/client/service.tsx
@@ -0,0 +1,397 @@
+import { useCallback, useEffect, useState } from "react";
+import type { StatusRpc } from "../../client/status-page.js";
+import { safeError } from "../../endpoint.js";
+import { ReMeClient } from "../../reme/client.js";
+import type { ServiceState } from "../types.js";
+import type { EnvTranslator } from "./locales.js";
+import { fill } from "./locales.js";
+import { StatusBadge } from "./presentation.js";
+import {
+ envCall,
+ parseServiceLog,
+ parseServiceStart,
+ parseServiceState,
+} from "./rpc.js";
+
+/** How long a detached start may take to answer `/health_check`. */
+const START_DEADLINE_MS = 90_000;
+
+export interface ServiceViewState {
+ phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
+ data?: ServiceState;
+ error?: string;
+}
+
+export interface ServiceLogView {
+ open: boolean;
+ lines: string;
+ logPath: string;
+}
+
+export interface ServiceControl {
+ state: ServiceViewState;
+ stopping: boolean;
+ start(): Promise;
+ stop(): Promise;
+ refresh(): void;
+ log: ServiceLogView;
+ viewLog(): Promise;
+ dismissLog(): void;
+}
+
+/**
+ * The service section's asynchronous state, keyed by endpoint: one status read
+ * per endpoint, plus the start and stop operations for the managed instance.
+ */
+export function useService(
+ rpc: StatusRpc,
+ options: {
+ t: EnvTranslator;
+ endpoint: string;
+ /** Only a loopback endpoint can be managed, so remote ones are read-only. */
+ local: boolean;
+ pythonPath: string;
+ workspaceDir: string;
+ },
+): ServiceControl {
+ const { t, endpoint, local, pythonPath, workspaceDir } = options;
+ const [state, setState] = useState({ phase: "checking" });
+ const [stopping, setStopping] = useState(false);
+ const [tick, setTick] = useState(0);
+ const [log, setLog] = useState({
+ open: false,
+ lines: "",
+ logPath: "",
+ });
+
+ useEffect(() => {
+ if (!local || !endpoint) {
+ setState({ phase: "checking" });
+ return;
+ }
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const next = parseServiceState(
+ await envCall(rpc, "serviceStatus", { endpoint }, controller.signal),
+ );
+ if (controller.signal.aborted) return;
+ setState((current) =>
+ current.phase === "starting"
+ ? current
+ : { phase: next.phase, data: next },
+ );
+ } catch (error) {
+ if (controller.signal.aborted) return;
+ setState((current) =>
+ current.phase === "starting"
+ ? current
+ : { phase: "error", error: safeError(error) },
+ );
+ }
+ })();
+ return () => controller.abort();
+ }, [rpc, endpoint, local, tick]);
+
+ const refresh = useCallback(() => setTick((current) => current + 1), []);
+
+ const start = useCallback(async () => {
+ if (!pythonPath || state.phase === "starting") return;
+ setState({ phase: "starting" });
+ setLog({ open: false, lines: "", logPath: "" });
+ try {
+ const started = parseServiceStart(
+ await envCall(rpc, "serviceStart", {
+ endpoint,
+ pythonPath,
+ workspaceDir,
+ }),
+ );
+ if (started.error) {
+ setState({ phase: "error", error: started.error });
+ return;
+ }
+ if (started.alreadyRunning) {
+ setState({ phase: "running" });
+ refresh();
+ return;
+ }
+ const deadline = Date.now() + START_DEADLINE_MS;
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ if (Date.now() > deadline) {
+ setState({ phase: "error", error: t("startTimeout") });
+ return;
+ }
+ try {
+ const status = parseServiceState(
+ await envCall(rpc, "serviceStatus", { endpoint }),
+ );
+ if (status.phase === "running") {
+ setState({ phase: "running", data: status });
+ return;
+ }
+ } catch {
+ // Keep polling: a starting service answers once it is listening.
+ }
+ }
+ } catch (error) {
+ setState({ phase: "error", error: safeError(error) });
+ }
+ }, [endpoint, pythonPath, refresh, rpc, state.phase, t, workspaceDir]);
+
+ const stop = useCallback(async () => {
+ if (stopping) return;
+ setStopping(true);
+ try {
+ await envCall(rpc, "serviceStop", { endpoint });
+ refresh();
+ } catch (error) {
+ setState({ phase: "error", error: safeError(error) });
+ } finally {
+ setStopping(false);
+ }
+ }, [endpoint, refresh, rpc, stopping]);
+
+ const viewLog = useCallback(async () => {
+ try {
+ const value = parseServiceLog(
+ await envCall(rpc, "serviceLog", { workspaceDir }),
+ );
+ setLog({ open: true, lines: value.lines, logPath: value.logPath });
+ } catch {
+ // Log viewing is diagnostic only.
+ }
+ }, [rpc, workspaceDir]);
+
+ const dismissLog = useCallback(
+ () => setLog({ open: false, lines: "", logPath: "" }),
+ [],
+ );
+
+ return { state, stopping, start, stop, refresh, log, viewLog, dismissLog };
+}
+
+export interface ConnectionState {
+ phase: "loading" | "ok" | "fail";
+ endpoint: string;
+ version?: string;
+ error?: string;
+}
+
+/**
+ * Health of a ReMe service that this plugin cannot manage locally (a remote
+ * URL), so the hero still reports whether the configured endpoint answers.
+ */
+export function useHealth(options: {
+ endpoint: string;
+ enabled: boolean;
+ tick: number;
+ requestTimeoutMs: number;
+ backgroundTimeoutMs: number;
+}): ConnectionState {
+ const { endpoint, enabled, tick, requestTimeoutMs, backgroundTimeoutMs } =
+ options;
+ const [health, setHealth] = useState({
+ phase: "loading",
+ endpoint: "",
+ });
+
+ useEffect(() => {
+ if (!enabled || !endpoint) return;
+ const controller = new AbortController();
+ const client = new ReMeClient({
+ endpoint,
+ requestTimeoutMs,
+ backgroundTimeoutMs,
+ });
+ void client
+ .healthCheck({ signal: controller.signal })
+ .then((result) => {
+ if (controller.signal.aborted) return;
+ setHealth(
+ result.ok
+ ? { phase: "ok", endpoint, version: result.health?.version ?? "" }
+ : { phase: "fail", endpoint, error: result.error },
+ );
+ })
+ .catch((error: unknown) => {
+ if (!controller.signal.aborted)
+ setHealth({ phase: "fail", endpoint, error: safeError(error) });
+ });
+ return () => controller.abort();
+ }, [endpoint, enabled, requestTimeoutMs, backgroundTimeoutMs, tick]);
+
+ return health;
+}
+
+/** Step 4: the local ReMe service that DSH talks to. */
+export function ServiceSection({
+ t,
+ endpoint,
+ pythonPath,
+ control,
+ remeInstalled,
+ autoStart,
+ writable,
+ onAutoStart,
+}: {
+ t: EnvTranslator;
+ endpoint: string;
+ pythonPath: string;
+ control: ServiceControl;
+ remeInstalled: boolean;
+ autoStart: boolean;
+ writable: boolean;
+ onAutoStart(checked: boolean): void;
+}): JSX.Element {
+ const { state, stopping, log } = control;
+ const [confirming, setConfirming] = useState(false);
+
+ return (
+
+
+
+ 4
+
+
+
{t("controlTitle")}
+
{t("serviceHint")}
+
+
+
+
+
+ {t("endpointLabel")}
+
+ {endpoint}
+
+
+
+ {state.phase === "running" ? (
+
+ {state.data?.managed
+ ? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
+ : t("foreignNote")}
+
+ ) : null}
+ {state.phase === "conflict" ? (
+
{t("conflictNote")}
+ ) : null}
+ {state.phase === "error" && state.error ? (
+
+ {`${t("serviceError")}: ${state.error}`}
+
+ ) : null}
+
+ {state.phase === "running" ? (
+ confirming ? (
+
+
{t("stopConfirm")}
+
+
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+
+ {log.open ? (
+
+
+ {`${t("envPath")}: ${log.logPath}`}
+
+
{log.lines || t("logEmpty")}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/integrations/dsh/src/environment/client/styles.ts b/integrations/dsh/src/environment/client/styles.ts
new file mode 100644
index 00000000..42f4bdfc
--- /dev/null
+++ b/integrations/dsh/src/environment/client/styles.ts
@@ -0,0 +1,84 @@
+/** Styles for the Environment tab: the hero, the step cards, and the .env form. */
+export const environmentStyles = `
+.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
+.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
+.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
+.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
+.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
+.reme-env-hero-copy{min-width:0}
+.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
+.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
+.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
+.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
+.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
+.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
+.reme-env-hero-error,.reme-env-notice{margin:0}
+.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
+.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
+.reme-env-card.collapsed>header{padding-bottom:14px}
+.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
+.reme-env-card>header>div{min-width:0;flex:1}
+.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
+.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
+.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
+.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
+.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
+.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
+.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
+.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
+.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
+.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
+.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
+.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
+.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
+.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
+.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
+.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
+.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
+.reme-env-note{margin:0}
+.reme-env-picker{display:flex;flex-direction:column;gap:9px}
+.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
+.reme-env-picker-bar .reme-settings-muted{margin:0}
+.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
+.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
+.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
+.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
+.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
+.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
+.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
+.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
+.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
+.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
+.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
+.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
+.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
+.reme-env-factrow{display:flex;align-items:center;gap:9px}
+.reme-env-factrow .reme-env-path{flex:1}
+.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
+.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
+.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
+.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
+.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
+.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
+.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
+.reme-env-metric .reme-status-badge i{margin-top:5px}
+.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
+.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
+.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
+.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
+.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
+.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
+.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
+.reme-env-progress{display:flex;flex-direction:column;gap:8px}
+.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
+.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
+.reme-env-result p{margin:0}
+.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
+.reme-env-inline{display:flex;align-items:center;gap:8px}
+.reme-env-inline input{flex:1;min-width:0}
+.reme-env-config{padding:2px 0 0}
+.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
+.reme-env .reme-settings-details{margin:0}
+.reme-env .reme-settings-details pre{margin:8px 0 0}
+@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
+`;
diff --git a/integrations/dsh/src/environment/client/tab.tsx b/integrations/dsh/src/environment/client/tab.tsx
index 0d512309..a59ee7ce 100644
--- a/integrations/dsh/src/environment/client/tab.tsx
+++ b/integrations/dsh/src/environment/client/tab.tsx
@@ -1,324 +1,23 @@
-import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
+import { useCallback, useState, useSyncExternalStore } from "react";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
-import { safeError } from "../../endpoint.js";
-import { ReMeClient } from "../../reme/client.js";
-import type { ReMeSettings } from "../../types.js";
import type { StatusRpc } from "../../client/status-page.js";
-import type {
- EnvSummary,
- InstallStatus,
- LlmTestResult,
- PythonDetection,
- PythonEnv,
- PythonOrigin,
- RemeCheck,
- ServiceLog,
- ServiceStartResult,
- ServiceState,
-} from "../types.js";
+import type { RemeCheck } from "../types.js";
+import type { ReMeSettings } from "../../types.js";
+import { DependencySection, useDependency } from "./dependency.js";
+import { fill, type EnvTranslator } from "./locales.js";
+import { StatusBadge, type Badge } from "./presentation.js";
+import { PythonSection, usePython, type PythonSelection } from "./python.js";
+import {
+ ServiceSection,
+ useHealth,
+ useService,
+ type ConnectionState,
+} from "./service.js";
+import { WorkspaceSection, useWorkspace } from "./workspace.js";
-const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
-
-/** Interpreter rows shown before the list asks to expand. */
-const ENV_PAGE = 6;
-
-export const envEn = {
- connectionTitle: "Service connection",
- endpointLabel: "Service URL",
- checking: "Checking…",
- connected: "Connected",
- disconnected: "Not connected",
- retest: "Test connection",
- remoteNotice:
- "This service URL is not local, so local environment management is disabled.",
- pythonTitle: "Local Python",
- pythonHint:
- "ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
- detecting: "Detecting Python environments…",
- detectFailed: "Detection failed",
- refresh: "Refresh",
- emptyTitle: "No usable Python found",
- emptyHint:
- "Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
- miniconda: "Miniconda download",
- recommended: "recommended",
- okBadge: "OK",
- tooOld: "Needs 3.11+",
- originConda: "Conda ({name})",
- originPythonOrg: "python.org installer",
- originStore: "Microsoft Store",
- originVenv: "venv environment",
- originSystem: "System PATH",
- originUnknown: "Unknown origin",
- errorsTitle: "Probe details",
- dependencyTitle: "ReMe dependency",
- dependencyHint:
- "The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
- interpreterLabel: "Interpreter",
- noInterpreter: "No interpreter selected",
- selectInterpreterFirst: "Select or install a Python interpreter above first.",
- recheck: "Check",
- checkingReme: "Checking ReMe installation…",
- checkRemeFailed: "ReMe check failed",
- remeLabel: "reme-ai",
- coreLabel: "[core] extras",
- cliLabel: "reme CLI",
- notInstalled: "Not installed",
- upgradeRecommended: "upgrade recommended",
- coreOk: "Available",
- coreMissing: "Missing (auto_memory / auto_dream unavailable)",
- cliFallback: "python wrapper fallback",
- installButton: "Install reme-ai[core]",
- upgradeButton: "Upgrade reme-ai",
- reinstall: "Reinstall reme-ai[core]",
- installConfirm:
- "Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
- confirmInstall: "Confirm install",
- cancel: "Cancel",
- installing: "Installing",
- cancelInstall: "Cancel install",
- waitingForOutput: "Waiting for pip output…",
- installSucceeded:
- "ReMe installed. Search, automatic memory and memory consolidation are now available.",
- installFailed: "Installation failed. See the log below.",
- installCancelled: "Installation cancelled.",
- dismiss: "Dismiss",
- mirrorLabel: "Package index",
- mirrorDefault: "Default (pip config)",
- mirrorPypi: "PyPI",
- mirrorTsinghua: "Tsinghua",
- mirrorAliyun: "Aliyun",
- condaHint:
- "In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
- configHint:
- "ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
- workspaceDir: "Workspace directory",
- workspaceDirHint:
- "Empty means ~/.reme. Restart the service after changing it.",
- saveWorkspace: "Save path",
- envLoading: "Loading .env…",
- envMissing: "No .env yet; saving creates it.",
- envPath: "File",
- llmGroup: "LLM (auto memory / consolidation)",
- embeddingGroup: "Embedding",
- backendLabel: "Backend",
- modelLabel: "Model",
- apiKeyLabel: "API key",
- baseUrlLabel: "Base URL",
- secretSet: "Set {masked}; leave blank to keep",
- secretUnset: "Not set",
- embeddingHint:
- "ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
- saveEnv: "Save to .env",
- savingEnv: "Saving…",
- savedEnv: "Saved to {path}",
- saveEnvFailed: "Saving failed",
- testLlmButton: "Test LLM connection",
- testingLlm: "Testing…",
- llmOk: "LLM reachable ({ms} ms)",
- llmFailed: "LLM test failed",
- serviceHint:
- "Start or stop the local ReMe service. The service keeps running after DSH closes.",
- serviceBadgeStarting: "Starting…",
- serviceBadgeConflict: "Port occupied",
- serviceBadgeStopped: "Stopped",
- serviceRunning: "Running",
- startService: "Start service",
- stopService: "Stop service",
- startTimeout: "The service did not become healthy within 90 seconds",
- stopConfirm:
- "Stop the ReMe service? Automatic memory in flight may need a retry.",
- foreignNote:
- "This service was started outside DSH; its state is shown but it is not managed here.",
- conflictNote:
- "Another program occupies this port. Change the service URL port in Memory settings.",
- viewLogs: "View log",
- logEmpty: "The service log is empty.",
- autoStartLabel: "Start the service automatically with DSH",
- managedNote: "Managed by this plugin · PID {pid}",
- serviceError: "Service operation failed",
- configTitle: "ReMe parameters",
- controlTitle: "Service control",
- heroEyebrow: "ReMe local runtime",
- heroReady: "Ready — memory runs locally",
- heroSetup: "Finish the setup in the steps below",
- heroService: "Service",
- heroPython: "Python",
- heroReme: "ReMe",
- noneSelected: "not selected",
- noneDetected: "not detected",
- envCount: "{count} environments",
- envRemeCount: "{count} with ReMe",
- envOthers: "{count} other environments",
- scannedAt: "Detected at {time}",
- currentInterpreter: "Current interpreter",
- notInDetection: "not detected",
- autoSelected:
- "The recommended interpreter is used until you pick another one.",
- savingSelection: "Saving…",
- changeInterpreter: "Change interpreter",
- collapseList: "Collapse list",
- searchPlaceholder: "Filter by version, origin or path",
- noMatch: "No environment matches the filter.",
- showAllEnvs: "Show all {count}",
- showFewer: "Show fewer",
- depReady: "Ready",
- depUpgrade: "Upgrade available",
- depMissing: "Not installed",
- expand: "Expand",
- collapse: "Collapse",
- recommendWhyReme:
- "Already has a supported reme-ai, and it is a dedicated conda environment.",
- recommendWhyRemeAny: "Already has a supported reme-ai.",
- recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.",
- recommendWhyFallback: "Fallback: the first usable interpreter.",
-};
-
-export const envZh: typeof envEn = {
- connectionTitle: "服务连接",
- endpointLabel: "服务地址",
- checking: "正在检查…",
- connected: "已连接",
- disconnected: "未连接",
- retest: "测试连接",
- remoteNotice: "当前服务地址不是本机地址,本地环境管理已停用。",
- pythonTitle: "本地 Python 环境",
- pythonHint:
- "ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
- detecting: "正在探测本地 Python 环境…",
- detectFailed: "探测失败",
- refresh: "重新探测",
- emptyTitle: "未检测到可用的 Python 环境",
- emptyHint:
- "可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
- miniconda: "下载 Miniconda",
- recommended: "推荐",
- okBadge: "可用",
- tooOld: "需要 3.11+",
- originConda: "Conda({name})",
- originPythonOrg: "python.org 安装器",
- originStore: "Microsoft Store",
- originVenv: "venv 虚拟环境",
- originSystem: "系统 PATH",
- originUnknown: "来源未知",
- errorsTitle: "探测细节",
- dependencyTitle: "ReMe 依赖",
- dependencyHint:
- "本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
- interpreterLabel: "解释器",
- noInterpreter: "尚未选择解释器",
- selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
- recheck: "检测",
- checkingReme: "正在检测 ReMe 安装…",
- checkRemeFailed: "ReMe 检测失败",
- remeLabel: "reme-ai",
- coreLabel: "[core] 依赖",
- cliLabel: "reme CLI",
- notInstalled: "未安装",
- upgradeRecommended: "建议升级",
- coreOk: "可用",
- coreMissing: "缺失(auto_memory / auto_dream 不可用)",
- cliFallback: "回退到 python 包装",
- installButton: "安装 reme-ai[core]",
- upgradeButton: "升级 reme-ai",
- reinstall: "重装 reme-ai[core]",
- installConfirm:
- "将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
- confirmInstall: "确认安装",
- cancel: "取消",
- installing: "正在安装",
- cancelInstall: "取消安装",
- waitingForOutput: "等待 pip 输出…",
- installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
- installFailed: "安装失败,请查看下方日志。",
- installCancelled: "安装已取消。",
- dismiss: "关闭",
- mirrorLabel: "软件源",
- mirrorDefault: "默认(pip 配置)",
- mirrorPypi: "PyPI 官方",
- mirrorTsinghua: "清华镜像",
- mirrorAliyun: "阿里云镜像",
- condaHint:
- "conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
- configHint:
- "ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
- workspaceDir: "Workspace 目录",
- workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
- saveWorkspace: "保存路径",
- envLoading: "正在读取 .env…",
- envMissing: "尚未创建 .env;保存后自动创建。",
- envPath: "文件",
- llmGroup: "LLM(自动记忆 / 记忆整理)",
- embeddingGroup: "Embedding",
- backendLabel: "后端",
- modelLabel: "模型",
- apiKeyLabel: "API Key",
- baseUrlLabel: "Base URL",
- secretSet: "已设置 {masked};留空保持不变",
- secretUnset: "未设置",
- embeddingHint:
- "ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
- saveEnv: "保存到 .env",
- savingEnv: "正在保存…",
- savedEnv: "已保存到 {path}",
- saveEnvFailed: "保存失败",
- testLlmButton: "测试 LLM 连接",
- testingLlm: "正在测试…",
- llmOk: "LLM 连接正常({ms} ms)",
- llmFailed: "LLM 测试失败",
- serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
- serviceBadgeStarting: "启动中…",
- serviceBadgeConflict: "端口被占用",
- serviceBadgeStopped: "已停止",
- serviceRunning: "运行中",
- startService: "启动服务",
- stopService: "停止服务",
- startTimeout: "服务在 90 秒内未就绪",
- stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
- foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
- conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
- viewLogs: "查看日志",
- logEmpty: "服务日志为空。",
- autoStartLabel: "随 DSH 启动时自动拉起服务",
- managedNote: "由本插件托管 · PID {pid}",
- serviceError: "服务操作失败",
- configTitle: "ReMe 参数",
- controlTitle: "服务控制",
- heroEyebrow: "ReMe 本地运行时",
- heroReady: "已就绪,记忆保存在本地",
- heroSetup: "按下方步骤完成配置",
- heroService: "服务",
- heroPython: "Python",
- heroReme: "ReMe",
- noneSelected: "未选择",
- noneDetected: "未探测",
- envCount: "{count} 个环境",
- envRemeCount: "{count} 个已装 ReMe",
- envOthers: "另有 {count} 个环境",
- scannedAt: "探测于 {time}",
- currentInterpreter: "当前解释器",
- notInDetection: "不在探测结果中",
- autoSelected: "当前使用推荐解释器;选择其他解释器后会写入配置。",
- savingSelection: "正在保存…",
- changeInterpreter: "更换解释器",
- collapseList: "收起列表",
- searchPlaceholder: "按版本、来源或路径筛选",
- noMatch: "没有匹配的环境。",
- showAllEnvs: "显示全部 {count} 个",
- showFewer: "收起多余项",
- depReady: "就绪",
- depUpgrade: "可升级",
- depMissing: "未安装",
- expand: "展开",
- collapse: "收起",
- recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。",
- recommendWhyRemeAny: "已安装可用的 reme-ai。",
- recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。",
- recommendWhyFallback: "兜底选择:第一个可用的解释器。",
-};
-
-export type EnvTranslator = (key: keyof typeof envEn) => string;
+export { envEn, envZh } from "./locales.js";
+export type { EnvTranslator } from "./locales.js";
export interface ReMeEnvironmentTabProps {
scope: ConfigForm;
@@ -326,62 +25,7 @@ export interface ReMeEnvironmentTabProps {
t: EnvTranslator;
}
-interface DetectState {
- phase: "loading" | "ready" | "error";
- data?: PythonDetection;
- error?: string;
-}
-
-interface HealthState {
- phase: "loading" | "ok" | "fail";
- /** The endpoint this result was measured against. */
- endpoint: string;
- version?: string;
- error?: string;
-}
-
-interface CheckState {
- phase: "idle" | "ready" | "error";
- data?: RemeCheck;
- error?: string;
- /** The interpreter this result belongs to. */
- forPath: string;
-}
-
-interface InstallState {
- phase: "idle" | "running" | "done";
- job?: InstallStatus;
- error?: string;
-}
-
-interface EnvState {
- phase: "loading" | "ready" | "error";
- data?: EnvSummary;
- error?: string;
-}
-
-interface EnvResultState {
- kind: "" | "saved" | "failed";
- message?: string;
-}
-
-interface LlmTestState {
- phase: "idle" | "running" | "ok" | "fail";
- result?: LlmTestResult;
-}
-
-interface ServiceViewState {
- phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
- data?: ServiceState;
- error?: string;
-}
-
-interface ServiceLogView {
- open: boolean;
- lines: string;
- logPath: string;
-}
-
+/** Only a loopback endpoint can be managed as a local service. */
function isLocalEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
@@ -391,86 +35,31 @@ function isLocalEndpoint(endpoint: string): boolean {
}
}
-function parseDetection(value: unknown): PythonDetection {
- if (
- typeof value !== "object" ||
- value === null ||
- !Array.isArray((value as { envs?: unknown }).envs)
- )
- throw new Error("Invalid environment detection response");
- return value as unknown as PythonDetection;
-}
-
-function parseRemeCheck(value: unknown): RemeCheck {
- if (typeof value !== "object" || value === null)
- throw new Error("Invalid ReMe check response");
- return value as unknown as RemeCheck;
-}
-
-function parseInstallStatus(value: unknown): InstallStatus | null {
- if (typeof value !== "object" || value === null) return null;
- const record = value as Record;
- return typeof record.id === "string" && typeof record.phase === "string"
- ? (value as unknown as InstallStatus)
- : null;
-}
-
-function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
- switch (origin.kind) {
- case "conda":
- return t("originConda").replace("{name}", origin.envName);
- case "python.org":
- return t("originPythonOrg");
- case "windows-store":
- return t("originStore");
- case "venv":
- return t("originVenv");
- case "system":
- return t("originSystem");
- default:
- return t("originUnknown");
- }
-}
-
-interface EnvBadge {
- tone: "" | "ok" | "bad" | "progress";
- text: string;
-}
-
-/** Human-readable reason behind the "recommended" marker. */
-function recommendReason(t: EnvTranslator, env: PythonEnv): string {
- const named = env.origin.kind === "conda" && env.origin.envName !== "base";
- if (env.reme?.installed)
- return t(named ? "recommendWhyReme" : "recommendWhyRemeAny");
- return t(named ? "recommendWhyNamed" : "recommendWhyFallback");
-}
-
function connectionBadge(
t: EnvTranslator,
- health: HealthState,
+ connection: ConnectionState,
current: boolean,
-): EnvBadge {
+): Badge {
const label = (detail: string) => `${t("heroService")} · ${detail}`;
- if (!current || health.phase === "loading")
+ if (!current || connection.phase === "loading")
return { tone: "", text: label(t("checking")) };
- if (health.phase === "ok")
+ if (connection.phase === "ok")
return {
tone: "ok",
- text: label(health.version ? `ReMe ${health.version}` : t("connected")),
+ text: label(
+ connection.version ? `ReMe ${connection.version}` : t("connected"),
+ ),
};
return { tone: "bad", text: label(t("disconnected")) };
}
-function interpreterBadge(
- t: EnvTranslator,
- env: PythonEnv | undefined,
- phase: DetectState["phase"],
-): EnvBadge {
+function interpreterBadge(t: EnvTranslator, selection: PythonSelection): Badge {
+ const env = selection.selectedEnv;
if (!env)
return {
tone: "",
text: `${t("heroPython")} · ${t(
- phase === "error" ? "noneDetected" : "noneSelected",
+ selection.detection.phase === "error" ? "noneDetected" : "noneSelected",
)}`,
};
return {
@@ -483,7 +72,7 @@ function remeSummaryBadge(
t: EnvTranslator,
check: RemeCheck | undefined,
checking: boolean,
-): EnvBadge {
+): Badge {
if (!check)
return {
tone: "",
@@ -500,26 +89,11 @@ function remeSummaryBadge(
};
}
-function dependencyBadge(
- t: EnvTranslator,
- check: RemeCheck | undefined,
-): EnvBadge {
- if (!check) return { tone: "", text: "" };
- if (!check.installed) return { tone: "progress", text: t("depMissing") };
- return check.versionOk && check.hasCore
- ? { tone: "ok", text: t("depReady") }
- : { tone: "progress", text: t("depUpgrade") };
-}
-
-function elapsedText(job: InstallStatus, now: number): string {
- const started = Date.parse(job.startedAt);
- if (!Number.isFinite(started) || now < started) return "";
- const seconds = Math.floor((now - started) / 1000);
- const minutes = Math.floor(seconds / 60);
- return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
-}
-
-/** Environment management panel: connection, Python, dependency, service. */
+/**
+ * The Environment tab: one hero with the local-runtime summary, then the four
+ * steps. Each step owns its own asynchronous state; this component only reads
+ * the settings document and lays the sections out.
+ */
export function ReMeEnvironmentTab({
scope,
rpc,
@@ -531,596 +105,86 @@ export function ReMeEnvironmentTab({
);
const value = snapshot.value;
const endpoint = value?.endpoint ?? "";
- const [detection, setDetection] = useState({
- phase: "loading",
- });
- const [health, setHealth] = useState({
- phase: "loading",
- endpoint: "",
- });
- const [check, setCheck] = useState({
- phase: "idle",
- forPath: "",
- });
- const [install, setInstall] = useState({ phase: "idle" });
- const [detectTick, setDetectTick] = useState(0);
- const [healthTick, setHealthTick] = useState(0);
- const [checkTick, setCheckTick] = useState(0);
- const [savingExe, setSavingExe] = useState("");
- const [confirming, setConfirming] = useState(false);
- const [mirror, setMirror] = useState("");
- const [now, setNow] = useState(0);
- const [env, setEnv] = useState({ phase: "loading" });
- const [envDraft, setEnvDraft] = useState>({});
- const [envSaving, setEnvSaving] = useState(false);
- const [envResult, setEnvResult] = useState({ kind: "" });
- const [llmTest, setLlmTest] = useState({ phase: "idle" });
- const [envTick, setEnvTick] = useState(0);
- const [workspaceInput, setWorkspaceInput] = useState(
- undefined,
- );
- const [service, setService] = useState({
- phase: "checking",
- });
- const [serviceTick, setServiceTick] = useState(0);
- const [stopping, setStopping] = useState(false);
- const [confirmingStop, setConfirmingStop] = useState(false);
- const [logView, setLogView] = useState({
- open: false,
- lines: "",
- logPath: "",
- });
- const [listOpen, setListOpen] = useState(false);
- const [envQuery, setEnvQuery] = useState("");
- const [showAllEnvs, setShowAllEnvs] = useState(false);
- const [paramsOpen, setParamsOpen] = useState(false);
-
- // Derived values must precede hook dependency arrays.
- const detected = detection.phase === "ready" ? detection.data : undefined;
- const selectedExe = value?.pythonPath ?? "";
- const suggestedExe = detected?.suggested ?? "";
- const selectedPath = selectedExe || suggestedExe;
- const checking = Boolean(selectedPath) && check.forPath !== selectedPath;
- const checkData = check.forPath === selectedPath ? check.data : undefined;
- const checkError = check.forPath === selectedPath ? check.error : undefined;
const local = isLocalEndpoint(endpoint);
- const healthCurrent =
- health.phase !== "loading" && health.endpoint === endpoint;
const workspaceDir = value?.remeWorkspaceDir ?? "";
- const workspaceDraft = workspaceInput ?? workspaceDir;
+ const [heroTick, setHeroTick] = useState(0);
- const loadDetection = useCallback(
- async (signal: AbortSignal, refresh: boolean) => {
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/python",
- { args: { input: { refresh } } },
- signal,
- );
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- setDetection((current) => ({
- ...current,
- phase: "ready",
- data: parseDetection(result.value),
- }));
- } catch (error) {
- if (!signal.aborted)
- setDetection((current) => ({
- ...current,
- phase: "error",
- error: safeError(error),
- }));
- }
+ const writeSetting = useCallback(
+ (field: string, next: unknown): Promise => {
+ if (!snapshot.writable) return Promise.resolve(false);
+ return scope
+ .mutate([{ op: "set", path: [field], value: next }], snapshot.revision)
+ .catch(() => undefined);
},
- [rpc],
+ [scope, snapshot.revision, snapshot.writable],
);
- useEffect(() => {
- const controller = new AbortController();
- void loadDetection(controller.signal, detectTick > 0);
- return () => controller.abort();
- }, [loadDetection, detectTick]);
-
- useEffect(() => {
- if (!endpoint) return;
- const controller = new AbortController();
- const client = new ReMeClient({
- endpoint,
- requestTimeoutMs: value?.requestTimeoutMs ?? 10000,
- backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000,
- });
- void client
- .healthCheck({ signal: controller.signal })
- .then((result) => {
- if (controller.signal.aborted) return;
- if (result.ok)
- setHealth({
- phase: "ok",
- endpoint,
- version: result.health?.version ?? "",
- });
- else setHealth({ phase: "fail", endpoint, error: result.error });
- })
- .catch((error: unknown) => {
- if (!controller.signal.aborted)
- setHealth({ phase: "fail", endpoint, error: safeError(error) });
- });
- return () => controller.abort();
- // Reload when connection settings or the manual retest tick change.
- }, [
+ const python = usePython(rpc, {
+ pythonPath: value?.pythonPath ?? "",
+ writable: snapshot.writable,
+ onSelect: (exe) => writeSetting("pythonPath", exe),
+ });
+ const dependency = useDependency(rpc, {
+ pythonPath: python.selectedPath,
+ onInstalled: () => {
+ python.refresh();
+ },
+ });
+ const workspace = useWorkspace(rpc, {
+ workspaceDir,
+ writable: snapshot.writable,
+ onWorkspaceDir: (dir) => writeSetting("remeWorkspaceDir", dir),
+ });
+ const service = useService(rpc, {
+ t,
endpoint,
- value?.requestTimeoutMs,
- value?.backgroundTimeoutMs,
- healthTick,
- ]);
-
- const ensureCheck = useCallback(
- async (signal: AbortSignal, pythonPath: string) => {
- if (!pythonPath) return;
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/checkReme",
- { args: { input: { pythonPath } } },
- signal,
- );
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- setCheck((current) => ({
- ...current,
- phase: "ready",
- data: parseRemeCheck(result.value),
- error: undefined,
- forPath: pythonPath,
- }));
- } catch (error) {
- if (!signal.aborted)
- setCheck((current) => ({
- ...current,
- phase: "error",
- error: safeError(error),
- forPath: pythonPath,
- }));
- }
- },
- [rpc],
- );
-
- useEffect(() => {
- const controller = new AbortController();
- void ensureCheck(controller.signal, selectedPath);
- return () => controller.abort();
- }, [ensureCheck, selectedPath, checkTick]);
-
- const restoreInstall = useCallback(
- async (signal: AbortSignal) => {
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/installStatus",
- { args: {} },
- signal,
- );
- if (!result.ok) return;
- const status = parseInstallStatus(result.value);
- if (status?.phase === "running")
- setInstall((current) => ({
- ...current,
- phase: "running",
- job: status,
- }));
- } catch {
- // Restoring is best-effort; a later action reports real failures.
- }
- },
- [rpc],
- );
-
- useEffect(() => {
- const controller = new AbortController();
- void restoreInstall(controller.signal);
- return () => controller.abort();
- }, [restoreInstall]);
-
- useEffect(() => {
- if (install.phase !== "running") return;
- const controller = new AbortController();
- let cancelled = false;
- let timer: ReturnType | undefined;
- const poll = async (): Promise => {
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/installStatus",
- { args: {} },
- controller.signal,
- );
- if (cancelled) return;
- if (!result.ok) {
- setInstall((current) => ({
- ...current,
- phase: "done",
- error: result.error.message,
- }));
- return;
- }
- const status = parseInstallStatus(result.value);
- if (!status) {
- setInstall({ phase: "idle" });
- return;
- }
- if (status.phase === "running") {
- setInstall((current) => ({
- ...current,
- phase: "running",
- job: status,
- }));
- timer = setTimeout(() => void poll(), 2000);
- return;
- }
- setInstall((current) => ({ ...current, phase: "done", job: status }));
- if (status.phase === "succeeded") {
- setCheckTick((tick) => tick + 1);
- setDetectTick((tick) => tick + 1);
- }
- } catch (error) {
- if (cancelled) return;
- setInstall((current) => ({
- ...current,
- phase: "done",
- error: safeError(error),
- }));
- }
- };
- void poll();
- return () => {
- cancelled = true;
- controller.abort();
- if (timer) clearTimeout(timer);
- };
- }, [install.phase, rpc]);
-
- useEffect(() => {
- if (install.phase !== "running") return;
- const timer = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(timer);
- }, [install.phase]);
-
- const loadEnv = useCallback(
- async (signal: AbortSignal, dir: string) => {
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/readEnv",
- { args: { input: { workspaceDir: dir } } },
- signal,
- );
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- setEnv((current) => ({
- ...current,
- phase: "ready",
- data: parseEnvSummary(result.value),
- }));
- } catch (error) {
- if (!signal.aborted)
- setEnv((current) => ({
- ...current,
- phase: "error",
- error: safeError(error),
- }));
- }
- },
- [rpc],
- );
-
- useEffect(() => {
- const controller = new AbortController();
- void loadEnv(controller.signal, workspaceDir);
- return () => controller.abort();
- }, [loadEnv, workspaceDir, envTick]);
-
- const loadService = useCallback(
- async (signal: AbortSignal) => {
- try {
- const result = await rpc.call(
- "/api",
- "remeEnv/serviceStatus",
- { args: { input: { endpoint } } },
- signal,
- );
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- const state = parseServiceState(result.value);
- setService((current) =>
- current.phase === "starting"
- ? current
- : { phase: state.phase, data: state },
- );
- } catch (error) {
- if (!signal.aborted)
- setService((current) =>
- current.phase === "starting"
- ? current
- : { phase: "error", error: safeError(error) },
- );
- }
- },
- [rpc, endpoint],
- );
-
- useEffect(() => {
- const controller = new AbortController();
- void loadService(controller.signal);
- return () => controller.abort();
- }, [loadService, serviceTick]);
+ local,
+ pythonPath: python.selectedPath,
+ workspaceDir,
+ });
+ // One health source per endpoint: the service probe already covers a local
+ // endpoint, so only a remote URL needs its own check.
+ const remote = useHealth({
+ endpoint,
+ enabled: !local,
+ tick: heroTick,
+ requestTimeoutMs: value?.requestTimeoutMs ?? 10000,
+ backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000,
+ });
+ const connection: ConnectionState = local
+ ? service.state.phase === "running"
+ ? { phase: "ok", endpoint, version: service.state.data?.version ?? "" }
+ : service.state.phase === "checking" || service.state.phase === "starting"
+ ? { phase: "loading", endpoint }
+ : { phase: "fail", endpoint, error: service.state.error }
+ : remote;
if (snapshot.status === "unavailable") return null;
- const select = (env: PythonEnv): void => {
- if (!value || !snapshot.writable || savingExe) return;
- setSavingExe(env.exe);
- void scope
- .mutate(
- [{ op: "set", path: ["pythonPath"], value: env.exe }],
- snapshot.revision,
- )
- .catch(() => undefined)
- .finally(() => setSavingExe(""));
- };
-
- const startInstall = async (upgrade: boolean): Promise => {
- if (!selectedPath || install.phase === "running") return;
- setConfirming(false);
- try {
- const result = await rpc.call("/api", "remeEnv/install", {
- args: {
- input: { pythonPath: selectedPath, upgrade, indexUrl: mirror },
- },
- });
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- const status = parseInstallStatus(result.value);
- setInstall(
- status ? { phase: "running", job: status } : { phase: "idle" },
- );
- } catch (error) {
- setInstall({ phase: "done", error: safeError(error) });
- }
- };
-
- const cancelInstall = async (): Promise => {
- const jobId = install.job?.id ?? "";
- try {
- await rpc.call("/api", "remeEnv/installCancel", {
- args: { input: { jobId } },
- });
- } catch {
- // Polling reflects the final phase either way.
- }
- };
-
- const envValue = (key: string): string => {
- const draft = envDraft[key];
- if (draft !== undefined) return draft;
- const entry = env.data?.entries.find((item) => item.key === key);
- return entry?.secret ? "" : entry?.value ?? "";
- };
-
- const envEntry = (key: string) =>
- env.data?.entries.find((item) => item.key === key);
-
- const updateEnvDraft = (key: string, next: string): void => {
- setEnvDraft((current) => ({ ...current, [key]: next }));
- setEnvResult({ kind: "" });
- };
-
- const saveEnv = async (): Promise => {
- if (!env.data || envSaving) return;
- const values: Record = {};
- for (const entry of env.data.entries) {
- if (!entry.secret) {
- const next = envValue(entry.key).trim();
- if (next !== "") values[entry.key] = next;
- continue;
- }
- const typed = envDraft[entry.key];
- if (typed !== undefined && typed !== "") values[entry.key] = typed;
- else if (!entry.set && entry.key === "EMBEDDING_API_KEY")
- values[entry.key] = "EMPTY";
- }
- setEnvSaving(true);
- setEnvResult({ kind: "" });
- try {
- const result = await rpc.call("/api", "remeEnv/writeEnv", {
- args: { input: { workspaceDir, values } },
- });
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- setEnv((current) => ({
- ...current,
- phase: "ready",
- data: parseEnvSummary(result.value),
- }));
- setEnvDraft({});
- setEnvResult({ kind: "saved" });
- } catch (error) {
- setEnvResult({ kind: "failed", message: safeError(error) });
- } finally {
- setEnvSaving(false);
- }
- };
-
- const runLlmTest = async (): Promise => {
- if (!selectedPath || llmTest.phase === "running") return;
- setLlmTest({ phase: "running" });
- try {
- const result = await rpc.call("/api", "remeEnv/testLlm", {
- args: { input: { workspaceDir, pythonPath: selectedPath } },
- });
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- const data = parseLlmResult(result.value);
- setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
- } catch (error) {
- setLlmTest({
- phase: "fail",
- result: {
- ok: false,
- status: 0,
- latencyMs: 0,
- error: safeError(error),
- },
- });
- }
- };
-
- const saveWorkspace = async (): Promise => {
- if (!value || !snapshot.writable) return;
- await scope
- .mutate(
- [
- {
- op: "set",
- path: ["remeWorkspaceDir"],
- value: workspaceDraft.trim(),
- },
- ],
- snapshot.revision,
- )
- .catch(() => undefined);
- setWorkspaceInput(undefined);
- setEnvTick((tick) => tick + 1);
- };
-
- const startService = async (): Promise => {
- if (!selectedPath || service.phase === "starting") return;
- setService({ phase: "starting" });
- setLogView({ open: false, lines: "", logPath: "" });
- try {
- const result = await rpc.call("/api", "remeEnv/serviceStart", {
- args: {
- input: { endpoint, pythonPath: selectedPath, workspaceDir },
- },
- });
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- const started = parseServiceStart(result.value);
- if (started.error) {
- setService({ phase: "error", error: started.error });
- return;
- }
- if (started.alreadyRunning) {
- setService({ phase: "running" });
- setServiceTick((tick) => tick + 1);
- return;
- }
- const deadline = Date.now() + 90_000;
- for (;;) {
- await new Promise((resolve) => setTimeout(resolve, 1000));
- if (Date.now() > deadline) {
- setService({ phase: "error", error: t("startTimeout") });
- return;
- }
- const statusResult = await rpc.call("/api", "remeEnv/serviceStatus", {
- args: { input: { endpoint } },
- });
- if (!statusResult.ok) continue;
- const status = parseServiceState(statusResult.value);
- if (status.phase === "running") {
- setService({ phase: "running", data: status });
- return;
- }
- }
- } catch (error) {
- setService({ phase: "error", error: safeError(error) });
- }
- };
-
- const stopService = async (): Promise => {
- if (stopping) return;
- setStopping(true);
- setConfirmingStop(false);
- try {
- const result = await rpc.call("/api", "remeEnv/serviceStop", {
- args: { input: { endpoint } },
- });
- if (!result.ok)
- throw new Error(result.error.message || result.error.code);
- setServiceTick((tick) => tick + 1);
- } catch (error) {
- setService({ phase: "error", error: safeError(error) });
- } finally {
- setStopping(false);
- }
- };
-
- const viewLog = async (): Promise => {
- try {
- const result = await rpc.call("/api", "remeEnv/serviceLog", {
- args: { input: { workspaceDir } },
- });
- if (!result.ok) return;
- const log = parseServiceLog(result.value);
- setLogView({ open: true, lines: log.lines, logPath: log.logPath });
- } catch {
- // Log viewing is diagnostic only.
- }
- };
-
- const selectedEnv = detected?.envs.find((env) => env.exe === selectedPath);
- const others = (detected?.envs ?? []).filter(
- (env) => env.exe !== selectedPath,
- );
- const filter = envQuery.trim().toLowerCase();
- const matches = filter
- ? others.filter((env) =>
- `${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
- .toLowerCase()
- .includes(filter),
- )
- : others;
- const visibleEnvs = showAllEnvs ? matches : matches.slice(0, ENV_PAGE);
- const hiddenCount = matches.length - visibleEnvs.length;
- const remeCount = (detected?.envs ?? []).filter(
- (env) => env.reme?.installed === true,
- ).length;
- const scannedAt = detected?.scannedAt
- ? Date.parse(detected.scannedAt)
+ const current =
+ connection.phase !== "loading" && connection.endpoint === endpoint;
+ const ready =
+ current &&
+ connection.phase === "ok" &&
+ dependency.checkData?.installed === true &&
+ dependency.checkData.versionOk;
+ const scannedAt = python.detection.data?.scannedAt
+ ? Date.parse(python.detection.data.scannedAt)
: Number.NaN;
const scannedTime = Number.isFinite(scannedAt)
? new Date(scannedAt).toLocaleTimeString()
: "";
- const ready =
- healthCurrent &&
- health.phase === "ok" &&
- checkData?.installed === true &&
- checkData.versionOk;
- const llmModel = envValue("LLM_MODEL_NAME").trim();
- const envSummaryLine =
- env.phase === "ready" && env.data
- ? `${env.data.exists ? env.data.envPath : t("envMissing")}${
- llmModel ? ` · ${llmModel}` : ""
- }`
- : t("envLoading");
const { tone: healthTone, text: healthText } = connectionBadge(
t,
- health,
- healthCurrent,
- );
- const { tone: pythonTone, text: pythonText } = interpreterBadge(
- t,
- selectedEnv,
- detection.phase,
+ connection,
+ current,
);
+ const { tone: pythonTone, text: pythonText } = interpreterBadge(t, python);
const { tone: remeTone, text: remeText } = remeSummaryBadge(
t,
- checkData,
- checking,
+ dependency.checkData,
+ dependency.checking,
);
- const { tone: depTone, text: depText } = dependencyBadge(t, checkData);
return (
@@ -1138,26 +202,17 @@ export function ReMeEnvironmentTab({
-
-
- {healthText}
-
-
-
- {pythonText}
-
-
-
- {remeText}
-
+ {healthText}
+ {pythonText}
+ {remeText}
- {healthCurrent && health.phase === "fail" && health.error ? (
+ {current && connection.phase === "fail" && connection.error ? (
- {health.error}
+ {connection.error}
) : null}
{!local ? (
@@ -1192,1021 +244,36 @@ export function ReMeEnvironmentTab({
) : (
<>
-
-
-
- 1
-
-
-
{t("pythonTitle")}
-
{t("pythonHint")}
-
- {detected && detected.envs.length > 0 ? (
-
- ) : null}
-
-
- {detection.phase === "loading" ? (
-
{t("detecting")}
- ) : null}
- {detection.phase === "error" ? (
-
- {t("detectFailed")}
- {detection.error ? `: ${detection.error}` : ""}
-
- ) : null}
- {detection.phase === "error" ||
- (detected && detected.envs.length === 0) ? (
-
- ) : null}
- {selectedEnv ? (
-
-
-
-
- {`Python ${selectedEnv.version || "?"}`}
-
- {originLabel(t, selectedEnv.origin)}
-
- {selectedEnv.reme?.installed ? (
- {`ReMe ${selectedEnv.reme.version}`}
- ) : null}
- {selectedEnv.exe === detected?.suggested ? (
-
- {t("recommended")}
-
- ) : null}
- {!selectedEnv.ok ? (
- {t("tooOld")}
- ) : null}
-
-
- {selectedEnv.exe}
-
-
- {savingExe === selectedEnv.exe ? (
-
- {t("savingSelection")}
-
- ) : null}
-
- ) : selectedExe ? (
-
-
-
-
- {t("currentInterpreter")}
-
- {t("notInDetection")}
-
-
-
- {selectedExe}
-
-
-
- ) : null}
- {!selectedExe && suggestedExe ? (
-
- {t("autoSelected")}
-
- ) : null}
- {others.length > 0 ? (
-
-
- setListOpen((open) => !open)}
- >
- {t(listOpen ? "collapseList" : "changeInterpreter")}
-
- {listOpen ? (
- {
- setEnvQuery(event.target.value);
- setShowAllEnvs(false);
- }}
- />
- ) : (
-
- {t("envOthers").replace(
- "{count}",
- String(others.length),
- )}
-
- )}
-
- {listOpen ? (
- <>
- {visibleEnvs.length > 0 ? (
-
- {visibleEnvs.map((env) => (
-
- ))}
-
- ) : (
-
{t("noMatch")}
- )}
- {hiddenCount > 0 ? (
-
setShowAllEnvs(true)}
- >
- {t("showAllEnvs").replace(
- "{count}",
- String(matches.length),
- )}
-
- ) : matches.length > ENV_PAGE ? (
-
setShowAllEnvs(false)}
- >
- {t("showFewer")}
-
- ) : null}
- >
- ) : null}
-
- ) : null}
- {detected && detected.errors.length > 0 ? (
-
- {t("errorsTitle")}
- {detected.errors.join("\n")}
-
- ) : null}
-
-
-
-
-
- 2
-
-
-
{t("dependencyTitle")}
-
{t("dependencyHint")}
-
- {depTone ? (
-
- ) : null}
-
-
-
-
- {t("interpreterLabel")}
-
-
- {selectedPath || t("noInterpreter")}
-
- {
- setCheck((current) => ({ ...current, phase: "idle" }));
- setCheckTick((tick) => tick + 1);
- }}
- >
- {t("recheck")}
-
-
- {!selectedPath ? (
-
- {t("selectInterpreterFirst")}
-
- ) : checking ? (
-
{t("checkingReme")}
- ) : null}
- {checkError ? (
-
- {t("checkRemeFailed")}
- {checkError ? `: ${checkError}` : ""}
-
- ) : null}
- {checkData ? (
-
-
-
{t("remeLabel")}
-
- {checkData.installed ? (
- <>
-
-
- {`reme-ai ${checkData.version}`}
-
- {checkData.versionOk ? null : (
- {t("upgradeRecommended")}
- )}
- >
- ) : (
-
-
- {t("notInstalled")}
-
- )}
-
-
-
-
{t("coreLabel")}
-
- {checkData.installed ? (
-
-
- {checkData.hasCore ? t("coreOk") : t("coreMissing")}
-
- ) : (
- —
- )}
-
-
-
-
{t("cliLabel")}
-
- {checkData.installed ? (
- checkData.cliPath ? (
-
- {checkData.cliPath}
-
- ) : (
-
-
- {t("cliFallback")}
-
- )
- ) : (
- —
- )}
-
-
-
- ) : null}
- {checkData && install.phase !== "running" ? (
-
-
- {confirming ? (
-
-
- {t("installConfirm").replace(
- "{path}",
- selectedPath || "",
- )}
-
-
void startInstall(!checkData.versionOk)}
- >
- {t("confirmInstall")}
-
-
setConfirming(false)}
- >
- {t("cancel")}
-
-
- ) : checkData.installed && checkData.versionOk ? (
-
setConfirming(true)}
- >
- {t("reinstall")}
-
- ) : checkData.installed ? (
-
setConfirming(true)}
- >
- {t("upgradeButton")}
-
- ) : (
-
setConfirming(true)}
- >
- {t("installButton")}
-
- )}
-
- {t("condaHint")}
-
-
- ) : null}
- {install.phase === "running" && install.job ? (
-
-
-
-
- {t("installing")}
- {elapsedText(install.job, now || Date.now())
- ? ` · ${elapsedText(install.job, now || Date.now())}`
- : ""}
-
- void cancelInstall()}
- >
- {t("cancelInstall")}
-
-
-
- {install.job.logTail || t("waitingForOutput")}
-
-
- ) : null}
- {install.phase === "done" ? (
-
- {install.job?.phase === "succeeded" ? (
-
- {t("installSucceeded")}
-
- ) : null}
- {install.job?.phase === "cancelled" ? (
-
- {t("installCancelled")}
-
- ) : null}
- {install.job?.phase === "failed" ? (
- <>
-
- {t("installFailed")}
-
- {install.job.logTail ? (
-
- {install.job.logTail}
-
- ) : null}
-
- {install.job.logPath}
-
- >
- ) : null}
- {install.error ? (
-
{install.error}
- ) : null}
-
{
- setInstall({ phase: "idle" });
- setConfirming(false);
- }}
- >
- {t("dismiss")}
-
-
- ) : null}
-
-
-
-
-
- 3
-
-
-
{t("configTitle")}
-
{envSummaryLine}
-
-
-
- {paramsOpen ? (
-
-
{t("configHint")}
-
-
-
-
- setWorkspaceInput(event.target.value)
- }
- />
- void saveWorkspace()}
- >
- {t("saveWorkspace")}
-
-
-
-
- {env.phase === "loading" ? (
-
{t("envLoading")}
- ) : null}
- {env.phase === "error" ? (
-
- {t("saveEnvFailed")}
- {env.error ? `: ${env.error}` : ""}
-
- ) : null}
- {env.phase === "ready" && env.data ? (
- <>
-
- {env.data.exists
- ? `${t("envPath")}: ${env.data.envPath}`
- : t("envMissing")}
-
-
-
{t("llmGroup")}
-
-
-
-
-
- updateEnvDraft("LLM_MODEL_NAME", event.target.value)
- }
- />
-
-
-
- updateEnvDraft("LLM_API_KEY", event.target.value)
- }
- />
-
-
-
- updateEnvDraft("LLM_BASE_URL", event.target.value)
- }
- />
-
- {t("embeddingGroup")}
-
-
-
-
-
- updateEnvDraft(
- "EMBEDDING_MODEL_NAME",
- event.target.value,
- )
- }
- />
-
-
-
- updateEnvDraft(
- "EMBEDDING_API_KEY",
- event.target.value,
- )
- }
- />
-
-
-
- updateEnvDraft(
- "EMBEDDING_BASE_URL",
- event.target.value,
- )
- }
- />
-
-
-
- void saveEnv()}
- >
- {t(envSaving ? "savingEnv" : "saveEnv")}
-
- void runLlmTest()}
- >
- {t(
- llmTest.phase === "running"
- ? "testingLlm"
- : "testLlmButton",
- )}
-
-
- {envResult.kind === "saved" ? (
-
- {t("savedEnv").replace(
- "{path}",
- env.data?.envPath ?? "",
- )}
-
- ) : null}
- {envResult.kind === "failed" ? (
-
- {`${t("saveEnvFailed")}${
- envResult.message ? `: ${envResult.message}` : ""
- }`}
-
- ) : null}
- {llmTest.phase === "ok" && llmTest.result ? (
-
- {t("llmOk").replace(
- "{ms}",
- String(llmTest.result.latencyMs),
- )}
-
- ) : null}
- {llmTest.phase === "fail" && llmTest.result ? (
-
- {`${t("llmFailed")}${
- llmTest.result.error
- ? `: ${llmTest.result.error}`
- : ""
- }`}
-
- ) : null}
-
-
- >
- ) : null}
-
- ) : null}
-
-
-
-
- 4
-
-
-
{t("controlTitle")}
-
{t("serviceHint")}
-
-
-
-
-
-
- {t("endpointLabel")}
-
-
- {endpoint}
-
- setServiceTick((tick) => tick + 1)}
- >
- {t("recheck")}
-
-
- {service.phase === "running" ? (
-
- {service.data?.managed
- ? t("managedNote").replace(
- "{pid}",
- String(service.data?.pid ?? ""),
- )
- : t("foreignNote")}
-
- ) : null}
- {service.phase === "conflict" ? (
-
{t("conflictNote")}
- ) : null}
- {service.phase === "error" && service.error ? (
-
- {`${t("serviceError")}: ${service.error}`}
-
- ) : null}
-
- {service.phase === "running" ? (
- confirmingStop ? (
-
-
{t("stopConfirm")}
-
void stopService()}
- >
- {t("stopService")}
-
-
setConfirmingStop(false)}
- >
- {t("cancel")}
-
-
- ) : (
-
setConfirmingStop(true)}
- >
- {t("stopService")}
-
- )
- ) : (
-
void startService()}
- >
- {t("startService")}
-
- )}
-
void viewLog()}
- >
- {t("viewLogs")}
-
-
-
- {logView.open ? (
-
-
- {`${t("envPath")}: ${logView.logPath}`}
-
-
- {logView.lines || t("logEmpty")}
-
-
- setLogView({ open: false, lines: "", logPath: "" })
- }
- >
- {t("dismiss")}
-
-
- ) : null}
-
-
+
+
+
+ {
+ void writeSetting("autoStart", checked);
+ }}
+ />
>
)}
);
}
-
-const LLM_BACKENDS = [
- "openai",
- "dashscope",
- "deepseek",
- "anthropic",
- "gemini",
- "moonshot",
- "ollama",
- "xai",
-] as const;
-const EMBEDDING_BACKENDS = [
- "openai",
- "dashscope",
- "dashscope_multimodal",
- "gemini",
- "ollama",
-] as const;
-
-function envOptions(list: readonly string[], current: string): string[] {
- return current && !list.includes(current) ? [...list, current] : [...list];
-}
-
-function EnvField({
- label,
- hint,
- children,
-}: {
- label: string;
- hint?: string;
- children: JSX.Element;
-}): JSX.Element {
- return (
-
-
- {children}
- {hint ? {hint} : null}
-
- );
-}
-
-function parseEnvSummary(value: unknown): EnvSummary {
- if (
- typeof value !== "object" ||
- value === null ||
- !Array.isArray((value as { entries?: unknown }).entries)
- )
- throw new Error("Invalid .env summary response");
- return value as unknown as EnvSummary;
-}
-
-function parseLlmResult(value: unknown): LlmTestResult {
- if (typeof value !== "object" || value === null)
- throw new Error("Invalid LLM test response");
- return value as unknown as LlmTestResult;
-}
-
-function parseServiceState(value: unknown): ServiceState {
- if (
- typeof value !== "object" ||
- value === null ||
- typeof (value as { phase?: unknown }).phase !== "string"
- )
- throw new Error("Invalid service status response");
- return value as unknown as ServiceState;
-}
-
-function parseServiceStart(value: unknown): ServiceStartResult {
- if (
- typeof value !== "object" ||
- value === null ||
- typeof (value as { started?: unknown }).started !== "boolean"
- )
- throw new Error("Invalid service start response");
- return value as unknown as ServiceStartResult;
-}
-
-function parseServiceLog(value: unknown): ServiceLog {
- if (
- typeof value !== "object" ||
- value === null ||
- typeof (value as { lines?: unknown }).lines !== "string"
- )
- throw new Error("Invalid service log response");
- return value as unknown as ServiceLog;
-}
diff --git a/integrations/dsh/src/environment/client/workspace.tsx b/integrations/dsh/src/environment/client/workspace.tsx
new file mode 100644
index 00000000..9e423ee1
--- /dev/null
+++ b/integrations/dsh/src/environment/client/workspace.tsx
@@ -0,0 +1,545 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { StatusRpc } from "../../client/status-page.js";
+import { safeError } from "../../endpoint.js";
+import type { EnvEntry, EnvSummary, LlmTestResult } from "../types.js";
+import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
+import { EnvField } from "./presentation.js";
+import { envCall, parseEnvSummary, parseLlmResult } from "./rpc.js";
+
+const LLM_BACKENDS = [
+ "openai",
+ "dashscope",
+ "deepseek",
+ "anthropic",
+ "gemini",
+ "moonshot",
+ "ollama",
+ "xai",
+] as const;
+const EMBEDDING_BACKENDS = [
+ "openai",
+ "dashscope",
+ "dashscope_multimodal",
+ "gemini",
+ "ollama",
+] as const;
+
+interface EnvFieldSpec {
+ key: string;
+ label: EnvKey;
+ kind: "select" | "text" | "secret";
+ options?: readonly string[];
+ placeholder?: string;
+ hint?: EnvKey;
+}
+
+interface EnvGroup {
+ title: EnvKey;
+ fields: EnvFieldSpec[];
+}
+
+/**
+ * The `.env` parameters this tab renders. One table drives both groups, so a
+ * new model knob is a row rather than a repeated block of controls.
+ */
+const ENV_GROUPS: EnvGroup[] = [
+ {
+ title: "llmGroup",
+ fields: [
+ {
+ key: "LLM_BACKEND",
+ label: "backendLabel",
+ kind: "select",
+ options: LLM_BACKENDS,
+ },
+ {
+ key: "LLM_MODEL_NAME",
+ label: "modelLabel",
+ kind: "text",
+ placeholder: "qwen3.7-plus",
+ },
+ { key: "LLM_API_KEY", label: "apiKeyLabel", kind: "secret" },
+ {
+ key: "LLM_BASE_URL",
+ label: "baseUrlLabel",
+ kind: "text",
+ placeholder: "https://…/v1",
+ },
+ ],
+ },
+ {
+ title: "embeddingGroup",
+ fields: [
+ {
+ key: "EMBEDDING_BACKEND",
+ label: "backendLabel",
+ kind: "select",
+ options: EMBEDDING_BACKENDS,
+ },
+ {
+ key: "EMBEDDING_MODEL_NAME",
+ label: "modelLabel",
+ kind: "text",
+ placeholder: "text-embedding-v4",
+ },
+ { key: "EMBEDDING_API_KEY", label: "apiKeyLabel", kind: "secret" },
+ {
+ key: "EMBEDDING_BASE_URL",
+ label: "baseUrlLabel",
+ kind: "text",
+ placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ hint: "embeddingHint",
+ },
+ ],
+ },
+];
+
+interface EnvState {
+ phase: "loading" | "ready" | "error";
+ data?: EnvSummary;
+ error?: string;
+ /** Workspace this summary was read for; a mismatch makes it stale. */
+ entriesFor?: string;
+}
+
+interface SaveResult {
+ kind: "" | "saved" | "failed";
+ message?: string;
+}
+
+interface LlmTestState {
+ phase: "idle" | "running" | "ok" | "fail";
+ result?: LlmTestResult;
+}
+
+interface Draft {
+ /** Workspace the drafts belong to; a mismatch means they are stale. */
+ dir: string;
+ /** Only keys the user touched, so an untouched file stays untouched. */
+ values: Record;
+}
+
+export interface WorkspaceState {
+ env: EnvState;
+ workspaceInput: string | undefined;
+ workspaceDraft: string;
+ editWorkspace(next: string): void;
+ saveWorkspace(): Promise;
+ value(key: string): string;
+ entry(key: string): EnvEntry | undefined;
+ edit(key: string, next: string): void;
+ saving: boolean;
+ result: SaveResult;
+ save(): Promise;
+ llmTest: LlmTestState;
+ testLlm(pythonPath: string): Promise;
+}
+
+/**
+ * The workspace section's asynchronous state: one `.env` read per workspace,
+ * and drafts bound to the workspace they were typed in.
+ */
+export function useWorkspace(
+ rpc: StatusRpc,
+ options: {
+ workspaceDir: string;
+ writable: boolean;
+ onWorkspaceDir(dir: string): Promise;
+ },
+): WorkspaceState {
+ const { workspaceDir, writable, onWorkspaceDir } = options;
+ const [env, setEnv] = useState({ phase: "loading" });
+ const [draft, setDraft] = useState({ dir: "", values: {} });
+ const [saving, setSaving] = useState(false);
+ const [result, setResult] = useState({ kind: "" });
+ const [llmTest, setLlmTest] = useState({ phase: "idle" });
+ const [tick, setTick] = useState(0);
+ const [workspaceInput, setWorkspaceInput] = useState();
+ // Read by a save that resolves after a workspace switch, so the answer for
+ // the previous workspace is dropped instead of shown against the new one.
+ const latestDir = useRef(workspaceDir);
+
+ useEffect(() => {
+ latestDir.current = workspaceDir;
+ }, [workspaceDir]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void (async () => {
+ try {
+ const value = await envCall(
+ rpc,
+ "readEnv",
+ { workspaceDir },
+ controller.signal,
+ );
+ setEnv({
+ phase: "ready",
+ data: parseEnvSummary(value),
+ entriesFor: workspaceDir,
+ });
+ } catch (error) {
+ if (!controller.signal.aborted)
+ setEnv({
+ phase: "error",
+ error: safeError(error),
+ entriesFor: workspaceDir,
+ });
+ }
+ })();
+ return () => controller.abort();
+ }, [rpc, workspaceDir, tick]);
+
+ // A workspace switch drops drafts typed for the previous workspace, so a key
+ // typed for one workspace can never be written into another. The read is
+ // scoped the same way: a summary for another workspace is not this view.
+ const values = useMemo(
+ () => (draft.dir === workspaceDir ? draft.values : {}),
+ [draft, workspaceDir],
+ );
+ const view = useMemo(
+ () =>
+ env.entriesFor === workspaceDir
+ ? env
+ : { phase: "loading" as const, data: undefined, error: undefined },
+ [env, workspaceDir],
+ );
+ const entry = useCallback(
+ (key: string) => view.data?.entries.find((item) => item.key === key),
+ [view.data],
+ );
+ const value = useCallback(
+ (key: string) => {
+ const typed = values[key];
+ if (typed !== undefined) return typed;
+ const stored = entry(key);
+ return stored?.secret ? "" : stored?.value ?? "";
+ },
+ [entry, values],
+ );
+ const edit = useCallback(
+ (key: string, next: string) => {
+ setDraft({ dir: workspaceDir, values: { ...values, [key]: next } });
+ setResult({ kind: "" });
+ },
+ [values, workspaceDir],
+ );
+
+ const save = useCallback(async () => {
+ const dir = workspaceDir;
+ const pending = values;
+ if (!view.data || saving) return;
+ // Only touched keys travel: an untouched key keeps its stored value, and an
+ // empty touched value is an explicit clear rather than "no opinion".
+ const payload: Record = { ...pending };
+ const embedding = view.data.entries.find(
+ (item) => item.key === "EMBEDDING_API_KEY",
+ );
+ if (
+ embedding &&
+ !embedding.set &&
+ pending["EMBEDDING_API_KEY"] === undefined
+ )
+ payload["EMBEDDING_API_KEY"] = "EMPTY";
+ setSaving(true);
+ setResult({ kind: "" });
+ try {
+ const summary = parseEnvSummary(
+ await envCall(rpc, "writeEnv", { workspaceDir: dir, values: payload }),
+ );
+ if (latestDir.current !== dir) return;
+ setEnv({ phase: "ready", data: summary, entriesFor: dir });
+ setDraft({ dir, values: {} });
+ setResult({ kind: "saved" });
+ } catch (error) {
+ if (latestDir.current !== dir) return;
+ setResult({ kind: "failed", message: safeError(error) });
+ } finally {
+ setSaving(false);
+ }
+ }, [rpc, saving, values, view.data, workspaceDir]);
+
+ const testLlm = useCallback(
+ async (pythonPath: string) => {
+ if (!pythonPath || llmTest.phase === "running") return;
+ setLlmTest({ phase: "running" });
+ try {
+ const data = parseLlmResult(
+ await envCall(rpc, "testLlm", { workspaceDir, pythonPath }),
+ );
+ setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
+ } catch (error) {
+ setLlmTest({
+ phase: "fail",
+ result: {
+ ok: false,
+ status: 0,
+ latencyMs: 0,
+ error: safeError(error),
+ },
+ });
+ }
+ },
+ [llmTest.phase, rpc, workspaceDir],
+ );
+
+ const saveWorkspace = useCallback(async () => {
+ if (!writable) return;
+ const next = workspaceInput ?? workspaceDir;
+ setWorkspaceInput(undefined);
+ await onWorkspaceDir(next.trim()).catch(() => undefined);
+ setTick((current) => current + 1);
+ }, [onWorkspaceDir, workspaceDir, workspaceInput, writable]);
+
+ return {
+ env: view,
+ workspaceInput,
+ workspaceDraft: workspaceInput ?? workspaceDir,
+ editWorkspace: setWorkspaceInput,
+ saveWorkspace,
+ value,
+ entry,
+ edit,
+ saving,
+ result,
+ save,
+ llmTest,
+ testLlm,
+ };
+}
+
+/** One `.env` control generated from the field table. */ function EnvControl({
+ t,
+ spec,
+ workspace,
+}: {
+ t: EnvTranslator;
+ spec: EnvFieldSpec;
+ workspace: WorkspaceState;
+}): JSX.Element {
+ const entry = workspace.entry(spec.key);
+ const hint = spec.hint ? t(spec.hint) : undefined;
+ if (spec.kind === "select")
+ return (
+
+
+
+ );
+ if (spec.kind === "secret")
+ return (
+
+ workspace.edit(spec.key, event.target.value)}
+ />
+
+ );
+ return (
+
+ workspace.edit(spec.key, event.target.value)}
+ />
+
+ );
+}
+
+/** Keep a value that is no longer offered as an option visible and selected. */
+function envChoices(list: readonly string[], current: string): string[] {
+ return current && !list.includes(current) ? [...list, current] : [...list];
+}
+
+/** Step 3: the workspace `.env` ReMe reads when the service starts. */
+export function WorkspaceSection({
+ t,
+ workspace,
+ pythonPath,
+ writable,
+}: {
+ t: EnvTranslator;
+ workspace: WorkspaceState;
+ pythonPath: string;
+ writable: boolean;
+}): JSX.Element {
+ const [open, setOpen] = useState(false);
+ const { env, llmTest, result } = workspace;
+
+ return (
+
+
+ {open ? (
+
+
{t("configHint")}
+
+
+
+
+ workspace.editWorkspace(event.target.value)
+ }
+ />
+ void workspace.saveWorkspace()}
+ >
+ {t("saveWorkspace")}
+
+
+
+
+ {env.phase === "loading" ? (
+
{t("envLoading")}
+ ) : null}
+ {env.phase === "error" ? (
+
+ {t("saveEnvFailed")}
+ {env.error ? `: ${env.error}` : ""}
+
+ ) : null}
+ {env.phase === "ready" && env.data ? (
+ <>
+
+ {env.data.exists
+ ? `${t("envPath")}: ${env.data.envPath}`
+ : t("envMissing")}
+
+
+ {ENV_GROUPS.map((group) => (
+
+ ))}
+
+
+ void workspace.save()}
+ >
+ {t(workspace.saving ? "savingEnv" : "saveEnv")}
+
+ void workspace.testLlm(pythonPath)}
+ >
+ {t(
+ llmTest.phase === "running"
+ ? "testingLlm"
+ : "testLlmButton",
+ )}
+
+
+ {result.kind === "saved" ? (
+
+ {fill(t("savedEnv"), { path: env.data.envPath })}
+
+ ) : null}
+ {result.kind === "failed" ? (
+
+ {`${t("saveEnvFailed")}${
+ result.message ? `: ${result.message}` : ""
+ }`}
+
+ ) : null}
+ {llmTest.phase === "ok" && llmTest.result ? (
+
+ {fill(t("llmOk"), { ms: llmTest.result.latencyMs })}
+
+ ) : null}
+ {llmTest.phase === "fail" && llmTest.result ? (
+
+ {`${t("llmFailed")}${
+ llmTest.result.error ? `: ${llmTest.result.error}` : ""
+ }`}
+
+ ) : null}
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+/** One titled group of `.env` controls. */
+function EnvGroupFields({
+ t,
+ group,
+ workspace,
+}: {
+ t: EnvTranslator;
+ group: EnvGroup;
+ workspace: WorkspaceState;
+}): JSX.Element {
+ return (
+ <>
+ {t(group.title)}
+ {group.fields.map((spec) => (
+
+ ))}
+ >
+ );
+}
diff --git a/integrations/dsh/src/environment/types.ts b/integrations/dsh/src/environment/types.ts
index d5d50ca9..b740a691 100644
--- a/integrations/dsh/src/environment/types.ts
+++ b/integrations/dsh/src/environment/types.ts
@@ -81,6 +81,8 @@ export interface InstallStatus {
logPath: string;
/** Tail of the install log; safe to poll. */
logTail: string;
+ /** True between a cancel request and the pip process actually exiting. */
+ cancelling?: boolean;
}
export interface EnvEntry {