From 0d9598e7c8e7ad190571865ff5ae1ac86e6fdfe5 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Tue, 27 Oct 2026 20:20:02 +0800 Subject: [PATCH] feat(dsh): restructure the environment tab for scanability The environment tab rendered as one flat column: five identical headings, every detected interpreter in one long list, and the whole .env form always expanded. That reads like prose and grows without bound on machines with dozens of conda environments. - Lead with a status hero (endpoint, service/Python/ReMe chips, retest and rescan) so the current state is readable at a glance. - Turn the four remaining groups into numbered cards with a status pill in the header, so setup order and per-step state are obvious. - Show only the interpreter in use, then reveal the rest on demand: search box, six rows per page, and a show-all toggle. - Collapse the .env editor behind a summary line (path + model). - Explain the "recommended" marker through a tooltip; the flag now also distinguishes an outdated reme-ai (amber) from a current one. - Expose versionOk on the detection probe so the client can tone the tag without importing host-only modules. - Add a react-test-renderer suite covering the hero, card count, collapsed list, pagination and interpreter selection. --- integrations/dsh/src/client/env-tab.tsx | 1809 ++++++++++++-------- integrations/dsh/src/client/styles.ts | 109 +- integrations/dsh/src/pyenv/reme.ts | 12 +- integrations/dsh/src/pyenv/types.ts | 2 +- integrations/dsh/tests/env-detect.test.mjs | 24 +- integrations/dsh/tests/env-tab.test.mjs | 231 +++ 6 files changed, 1479 insertions(+), 708 deletions(-) create mode 100644 integrations/dsh/tests/env-tab.test.mjs diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx index 18a3efe4..b556020e 100644 --- a/integrations/dsh/src/client/env-tab.tsx +++ b/integrations/dsh/src/client/env-tab.tsx @@ -21,6 +21,9 @@ import type { StatusRpc } from "./status-page.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", @@ -138,8 +141,39 @@ export const envEn = { serviceError: "Service operation failed", configTitle: "ReMe parameters", controlTitle: "Service control", - upcoming: - "This section arrives with the next milestone of the environment feature.", + 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 = { @@ -252,7 +286,37 @@ export const envZh: typeof envEn = { serviceError: "服务操作失败", configTitle: "ReMe 参数", controlTitle: "服务控制", - upcoming: "该分区将随环境管理特性的后续里程碑提供。", + 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; @@ -369,6 +433,85 @@ function originLabel(t: EnvTranslator, origin: PythonOrigin): string { } } +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, + current: boolean, +): EnvBadge { + const label = (detail: string) => `${t("heroService")} · ${detail}`; + if (!current || health.phase === "loading") + return { tone: "", text: label(t("checking")) }; + if (health.phase === "ok") + return { + tone: "ok", + text: label(health.version ? `ReMe ${health.version}` : t("connected")), + }; + return { tone: "bad", text: label(t("disconnected")) }; +} + +function interpreterBadge( + t: EnvTranslator, + env: PythonEnv | undefined, + phase: DetectState["phase"], +): EnvBadge { + if (!env) + return { + tone: "", + text: `${t("heroPython")} · ${t( + phase === "error" ? "noneDetected" : "noneSelected", + )}`, + }; + return { + tone: env.ok ? "ok" : "progress", + text: `${t("heroPython")} ${env.version || "?"}`, + }; +} + +function remeSummaryBadge( + t: EnvTranslator, + check: RemeCheck | undefined, + checking: boolean, +): EnvBadge { + if (!check) + return { + tone: "", + text: `${t("heroReme")} · ${t(checking ? "checking" : "noneDetected")}`, + }; + if (!check.installed) + return { + tone: "progress", + text: `${t("heroReme")} · ${t("notInstalled")}`, + }; + return { + tone: check.versionOk && check.hasCore ? "ok" : "progress", + text: `${t("heroReme")} ${check.version}`, + }; +} + +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 ""; @@ -428,6 +571,10 @@ export function ReMeEnvironmentTab({ 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; @@ -924,29 +1071,88 @@ export function ReMeEnvironmentTab({ } }; + 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) + : 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, + ); + const { tone: remeTone, text: remeText } = remeSummaryBadge( + t, + checkData, + checking, + ); + const { tone: depTone, text: depText } = dependencyBadge(t, checkData); + return (
-
-

{t("connectionTitle")}

-
- {t("endpointLabel")} - {endpoint} - {!healthCurrent ? ( - - - {t("checking")} - - ) : health.phase === "ok" ? ( - - - {health.version ? `ReMe ${health.version}` : t("connected")} - - ) : ( - - - {t("disconnected")} - - )} +
+
+ +
+ {t("heroEyebrow")} +

{t(ready ? "heroReady" : "heroSetup")}

+

+ {endpoint} +

+
+
+
+ + + {healthText} + + + + {pythonText} + + + + {remeText} + +
+
+ {local ? ( + + ) : null} + {scannedTime ? ( + + {t("scannedAt").replace("{time}", scannedTime)} + + ) : null}
- {healthCurrent && health.phase === "fail" && health.error ? ( -

{health.error}

- ) : null}
+ {healthCurrent && health.phase === "fail" && health.error ? ( +

+ {health.error} +

+ ) : null} {!local ? (

{t("remoteNotice")}

) : ( <> -
-

{t("pythonTitle")}

-

{t("pythonHint")}

-
- {detection.phase === "loading" ? ( - {t("detecting")} - ) : null} - -
- {detection.phase === "error" ? ( -

- {t("detectFailed")} - {detection.error ? `: ${detection.error}` : ""} -

- ) : null} - {detection.phase === "error" || - (detected && detected.envs.length === 0) ? ( -
- {t("emptyTitle")} - {t("emptyHint")} - - {t("miniconda")} - +
+
+ +
+

{t("pythonTitle")}

+

{t("pythonHint")}

- ) : null} - {detected && detected.envs.length > 0 ? ( -
- {detected.envs.map((env) => ( - - ))} -
- ) : null} - {detected && detected.errors.length > 0 ? ( -
- {t("errorsTitle")} -
{detected.errors.join("\n")}
-
- ) : null} -
-
-

{t("dependencyTitle")}

-

{t("dependencyHint")}

-
- {t("interpreterLabel")} - - {selectedPath || t("noInterpreter")} - - -
- {!selectedPath ? ( -

- {t("selectInterpreterFirst")} -

- ) : checking ? ( -

{t("checkingReme")}

- ) : null} - {checkError ? ( -

- {t("checkRemeFailed")} - {checkError ? `: ${checkError}` : ""} -

- ) : null} - {checkData ? ( -
-
- {t("remeLabel")} - {checkData.installed ? ( - - - {`reme-ai ${checkData.version}`} - {checkData.versionOk - ? "" - : ` · ${t("upgradeRecommended")}`} - - ) : ( - - - {t("notInstalled")} - - )} -
-
- {t("coreLabel")} - {checkData.installed ? ( - - - {checkData.hasCore ? t("coreOk") : t("coreMissing")} - - ) : ( - — - )} -
-
- {t("cliLabel")} - {checkData.installed ? ( - - - {checkData.cliPath || t("cliFallback")} - - ) : ( - — - )} -
-
- ) : null} - {checkData && install.phase !== "running" ? ( -
- - {confirming ? ( -
-

- {t("installConfirm").replace( - "{path}", - selectedPath || "", - )} -

- - -
- ) : checkData.installed && checkData.versionOk ? ( - - ) : checkData.installed ? ( - - ) : ( - - )} -

- {t("condaHint")} -

-
- ) : null} - {install.phase === "running" && install.job ? ( -
-
- - - {t("installing")} - {elapsedText(install.job, now || Date.now()) - ? ` · ${elapsedText(install.job, now || Date.now())}` + {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) ? ( +
+ {t("emptyTitle")} + {t("emptyHint")} + - {t("cancelInstall")} - + {t("miniconda")} +
-
-                  {install.job.logTail || t("waitingForOutput")}
-                
+ ) : null} + {selectedEnv ? ( +
+
+ ) : selectedExe ? ( +
+
+ ) : null} + {!selectedExe && suggestedExe ? ( +

+ {t("autoSelected")} +

+ ) : null} + {others.length > 0 ? ( +
+
+ + {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 ? ( + + ) : matches.length > ENV_PAGE ? ( + + ) : null} + + ) : null} +
+ ) : null} + {detected && detected.errors.length > 0 ? ( +
+ {t("errorsTitle")} +
{detected.errors.join("\n")}
+
+ ) : null} +
+
+
+
+ +
+

{t("dependencyTitle")}

+

{t("dependencyHint")}

- ) : 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} + {depTone ? ( + + ) : null} +
+
+
+ + {t("interpreterLabel")} + + + {selectedPath || t("noInterpreter")} +
- ) : null} -
-
-

{t("configTitle")}

-

{t("configHint")}

-
- -
- setWorkspaceInput(event.target.value)} - /> - -
-
-
- {env.phase === "loading" ? ( -

{t("envLoading")}

- ) : null} - {env.phase === "error" ? ( -

- {t("saveEnvFailed")} - {env.error ? `: ${env.error}` : ""} -

- ) : null} - {env.phase === "ready" && env.data ? ( - <> + {!selectedPath ? (

- {env.data.exists - ? `${t("envPath")}: ${env.data.envPath}` - : t("envMissing")} + {t("selectInterpreterFirst")}

-
-
{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, +
+
+
+ {t("coreLabel")} +
+ {checkData.installed ? ( + + + {checkData.hasCore ? t("coreOk") : t("coreMissing")} + + ) : ( + — + )} +
+
+
+ {t("cliLabel")} +
+ {checkData.installed ? ( + checkData.cliPath ? ( + + {checkData.cliPath} + + ) : ( + + + {t("cliFallback")} + ) - } - /> - - - - updateEnvDraft("EMBEDDING_API_KEY", event.target.value) - } - /> - - - - updateEnvDraft("EMBEDDING_BASE_URL", event.target.value) - } - /> - + ) : ( + — + )} +
+
-
- - - - {envResult.kind === "saved" ? ( - - {t("savedEnv").replace( + ) : null} + {checkData && install.phase !== "running" ? ( +
+ + {confirming ? ( +
+

+ {t("installConfirm").replace( "{path}", - env.data?.envPath ?? "", + selectedPath || "", )} - - ) : 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} -
-
-

{t("controlTitle")}

-

{t("serviceHint")}

-
- {t("endpointLabel")} - {endpoint} - {service.phase === "checking" || service.phase === "starting" ? ( - - - {t( - service.phase === "starting" - ? "serviceBadgeStarting" - : "checking", - )} - - ) : service.phase === "running" ? ( - - - {service.data?.version - ? `ReMe ${service.data.version}` - : t("serviceRunning")} - - ) : service.phase === "conflict" ? ( - - - {t("serviceBadgeConflict")} - - ) : ( - - - {t("serviceBadgeStopped")} - - )} - -
- {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")}

+

+ + +
+ ) : checkData.installed && checkData.versionOk ? ( + + ) : checkData.installed ? ( + ) : ( + + )} +

+ {t("condaHint")} +

+
+ ) : null} + {install.phase === "running" && install.job ? ( +
+
+ + + {t("installing")} + {elapsedText(install.job, now || Date.now()) + ? ` · ${elapsedText(install.job, now || Date.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} +
+ +
+
+ +
+

{t("configTitle")}

+

{envSummaryLine}

+
+ +
+ {paramsOpen ? ( +
+

{t("configHint")}

+
+ +
+ + setWorkspaceInput(event.target.value) + } + /> + +
+
+
+ {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, + ) + } + /> + +
+
+ + + + {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} +
+
+
+ +
+

{t("controlTitle")}

+

{t("serviceHint")}

+
+ +
+
+
+ + {t("endpointLabel")} + + + {endpoint} + + +
+ {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")}

+ + +
+ ) : ( + - -
+ ) ) : ( - ) - ) : ( - - )} - - -
- {logView.open ? ( -
-

- {`${t("envPath")}: ${logView.logPath}`} -

-
-                  {logView.lines || t("logEmpty")}
-                
+ )} +
- ) : null} + {logView.open ? ( +
+

+ {`${t("envPath")}: ${logView.logPath}`} +

+
+                    {logView.lines || t("logEmpty")}
+                  
+ +
+ ) : null} +
)} diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index 5abb7639..1801be46 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -37,39 +37,86 @@ 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} -.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0} -.reme-env-row+.reme-settings-error{margin:0} -.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500} -.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px} -.reme-env-notice{margin:12px 0 0} -.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer} -.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)} -.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px} -.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5} -.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere} -.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5} -.reme-env-hint strong{color:var(--dsw-alias-label-primary)} -.reme-env-upcoming{margin:0;padding:4px 0 12px} -.reme-env .reme-settings-details{margin-top:8px} -.reme-env-checks{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px} -.reme-env-check{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:12px} -.reme-env-check>span:first-child{flex:none;font-weight:500;color:var(--dsw-alias-label-primary)} -.reme-env-check .reme-status-badge{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)} -.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{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-hint-inline{flex:1 0 100%;margin:0} -.reme-env-progress{padding-top:10px} -.reme-env-log{max-height:220px;overflow:auto;margin:6px 0 0;border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word} -.reme-env-result{padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-inline{display:flex;gap:8px;align-items:center} +.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:4px 0 0} -.reme-env-group{margin:14px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;letter-spacing:.02em} -.reme-env-check-inline{padding:0;border: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/pyenv/reme.ts b/integrations/dsh/src/pyenv/reme.ts index 17066ba1..4c07ac9e 100644 --- a/integrations/dsh/src/pyenv/reme.ts +++ b/integrations/dsh/src/pyenv/reme.ts @@ -17,6 +17,8 @@ export interface RemeIO { export interface RemeProbe { installed: boolean; version: string; + /** True when the version satisfies {@link MINIMUM_REME}. */ + versionOk: boolean; } export interface RemeCheck extends RemeProbe { @@ -68,11 +70,15 @@ export async function probeReme( timeoutMs: 10_000, }); if (result.error || result.code !== 0) - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; const version = parseRemeVersion(result.stdout); - return { installed: version !== "", version }; + return { + installed: version !== "", + version, + versionOk: remeVersionAtLeast(version), + }; } catch { - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; } } diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts index 977b3c79..f0ad2c9e 100644 --- a/integrations/dsh/src/pyenv/types.ts +++ b/integrations/dsh/src/pyenv/types.ts @@ -18,7 +18,7 @@ export interface PythonEnv { /** Human-readable discovery channels, e.g. "conda env list; PATH". */ source: string; /** reme-ai installation facts, probed for the interpreter recommendation. */ - reme?: { installed: boolean; version: string }; + reme?: { installed: boolean; version: string; versionOk?: boolean }; } export interface PythonDetection { diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs index 8450d08a..020cee9c 100644 --- a/integrations/dsh/tests/env-detect.test.mjs +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -394,10 +394,26 @@ test("detects, classifies and suggests Windows interpreters end to end", async ( assert.equal(store.origin.kind, "windows-store"); assert.equal(store.ok, true); - assert.deepEqual(base.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(named.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(pythonOrg.reme, { installed: true, version: "0.4.1.11" }); - assert.deepEqual(store.reme, { installed: false, version: "" }); + assert.deepEqual(base.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(named.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(pythonOrg.reme, { + installed: true, + version: "0.4.1.11", + versionOk: true, + }); + assert.deepEqual(store.reme, { + installed: false, + version: "", + versionOk: false, + }); }); test("suggests nothing usable by preferring reme installs, then named conda envs", () => { diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs new file mode 100644 index 00000000..6278745b --- /dev/null +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { act, create } from "react-test-renderer"; + +import { ReMeEnvironmentTab, envZh } from "../dist/client/env-tab.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const CONDA = "D:\\ProgramFils\\Miniconda"; +const SUGGESTED = `${CONDA}\\envs\\reme-serve\\python.exe`; + +function env(name, version, reme) { + const root = name === "base" ? CONDA : `${CONDA}\\envs\\${name}`; + return { + exe: `${root}\\python.exe`, + version, + ok: true, + origin: { kind: "conda", envName: name, root }, + source: "conda env list", + ...(reme ? { reme: { installed: true, version: reme } } : {}), + }; +} + +const ENVS = [ + env("base", "3.12.3", "0.4.0.9"), + env("com_env", "3.12.9"), + env("copaw", "3.12.0", "0.3.1.8"), + env("drama", "3.12.13"), + env("mochi", "3.12.13"), + env("mochi_test", "3.12.13"), + env("nl_agent", "3.12.0"), + env("reme-serve", "3.12.13", "0.4.0.9"), +]; + +const RESPONSES = { + "remeEnv/python": { + platform: "win32", + scannedAt: new Date().toISOString(), + envs: ENVS, + suggested: SUGGESTED, + condaRoot: CONDA, + errors: [], + }, + "remeEnv/checkReme": { + installed: true, + version: "0.4.0.9", + versionOk: true, + hasCore: true, + cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`, + }, + "remeEnv/installStatus": null, + "remeEnv/readEnv": { + exists: true, + envPath: "C:\\Users\\test\\.reme\\.env", + entries: [ + { key: "LLM_BACKEND", value: "dashscope", secret: false, set: true }, + { + key: "LLM_MODEL_NAME", + value: "qwen3.7-plus", + secret: false, + set: true, + }, + ], + }, + "remeEnv/serviceStatus": { + phase: "running", + version: "0.4.0.9", + managed: false, + pid: 98820, + endpoint: "http://127.0.0.1:2333", + }, +}; + +const t = (key) => envZh[key]; + +function textOf(value) { + if (Array.isArray(value)) return value.map(textOf).join(""); + return typeof value === "string" ? value : ""; +} + +function classesOf(node, found = new Map()) { + if (!node || typeof node === "string") return found; + if (Array.isArray(node)) { + node.forEach((child) => classesOf(child, found)); + return found; + } + for (const name of String(node.props.className ?? "").split(" ")) { + if (name) found.set(name, (found.get(name) ?? 0) + 1); + } + (node.children ?? []).forEach((child) => classesOf(child, found)); + return found; +} + +function texts(node, found = []) { + if (!node) return found; + if (typeof node === "string") { + found.push(node); + return found; + } + if (Array.isArray(node)) { + node.forEach((child) => texts(child, found)); + return found; + } + (node.children ?? []).forEach((child) => texts(child, found)); + return found; +} + +async function mount() { + const calls = { mutate: [] }; + const snapshot = { + status: "ready", + writable: true, + revision: 3, + value: { + endpoint: "http://127.0.0.1:2333", + pythonPath: "", + remeWorkspaceDir: "", + autoStart: false, + requestTimeoutMs: 10000, + backgroundTimeoutMs: 3600000, + }, + }; + const scope = { + subscribe: () => () => undefined, + getSnapshot: () => snapshot, + mutate: async (ops, revision) => { + calls.mutate.push({ ops, revision }); + return true; + }, + }; + const rpc = { + call: async (_path, method) => ({ + ok: true, + value: RESPONSES[method] ?? null, + }), + }; + + let renderer; + await act(async () => { + renderer = create( + React.createElement(ReMeEnvironmentTab, { scope, rpc, t }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + return { renderer, calls }; +} + +function buttonByText(renderer, label) { + const found = renderer.root.findAll( + (node) => node.type === "button" && textOf(node.props.children) === label, + ); + assert.ok(found.length > 0, `button not found: ${label}`); + return found[0]; +} + +test("renders the status hero and the four setup steps", async () => { + const { renderer } = await mount(); + const json = renderer.toJSON(); + const classes = classesOf(json); + const copy = texts(json).join(" "); + + assert.equal(classes.get("reme-env-hero"), 1); + assert.equal(classes.get("reme-env-card"), 4); + assert.equal(classes.get("reme-env-picked"), 1); + assert.equal(classes.get("reme-env-metrics"), 1); + assert.equal(classes.get("reme-env-autostart"), 1); + + assert.match(copy, /ReMe 本地运行时/); + assert.match(copy, /已就绪,记忆保存在本地/); + assert.match(copy, /http:\/\/127\.0\.0\.1:2333/); + assert.match(copy, /8 个环境/); + assert.match(copy, /服务 · ReMe 0\.4\.0\.9/); + assert.match(copy, /Python 3\.12\.13/); + assert.match(copy, /reme-ai 0\.4\.0\.9/); + assert.match(copy, /C:\\Users\\test\\\.reme\\\.env/); +}); + +test("keeps the interpreter list collapsed until asked, then paginates", async () => { + const { renderer } = await mount(); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), undefined); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const expanded = classesOf(renderer.toJSON()); + assert.equal(expanded.get("reme-env-option"), 6); + assert.equal(expanded.get("reme-env-search"), 1); + assert.equal(expanded.get("reme-env-more"), 1); + + await act(async () => { + renderer.root + .findAll( + (node) => + node.type === "button" && textOf(node.props.children).includes("7"), + )[0] + .props.onClick(); + }); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 7); +}); + +test("marks the suggested interpreter and explains why", async () => { + const { renderer, calls } = await mount(); + const json = renderer.toJSON(); + const recommended = renderer.root.findAll( + (node) => + String(node.props.className ?? "").includes("reme-env-tag brand") && + textOf(node.props.children) === envZh.recommended, + ); + assert.ok(recommended.length >= 1); + assert.equal(recommended[0].props.title, envZh.recommendWhyReme); + assert.ok(texts(json).join(" ").includes(SUGGESTED)); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const radios = renderer.root.findAll( + (node) => node.type === "input" && node.props.type === "radio", + ); + const target = radios.find((node) => node.props.disabled === false); + assert.ok(target); + await act(async () => { + target.props.onChange(); + }); + assert.deepEqual(calls.mutate.at(-1)?.ops, [ + { op: "set", path: ["pythonPath"], value: ENVS[0].exe }, + ]); +});