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.
This commit is contained in:
LiangshouX 2026-10-27 20:20:02 +08:00
parent 80ce19645e
commit 0d9598e7c8
6 changed files with 1479 additions and 708 deletions

File diff suppressed because it is too large Load diff

View file

@ -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}}
`;

View file

@ -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 };
}
}

View file

@ -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 {

View file

@ -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", () => {

View file

@ -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 },
]);
});