feat(studio): keep the mod host's settings out of sight until the kernel allows it

This commit is contained in:
esengine committed 2026-10-17 21:38:04 -07:00
1 parent fd41bf51ce
commit b3555c6c81
8 files changed
+80 -34

No files matched your search

+39 -21
View File
@@ -4,6 +4,7 @@ import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
const SHOWN = process.env.MODS_SHOWN === "1";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
@@ -15,6 +16,15 @@ const band = { kind: "extension_surface", extension: { pluginId: "statusline-clo
] } } };
const ask = { kind: "extension_surface", extension: { pluginId: "git-guard", surfaceId: "mod-ask", kind: "form", sessionId: "s", generation: 1, form: { title: "git-guard asks", message: "要删除已经合并的分支 old-spike 吗?", fields: [{ key: "answer", label: "回答", kind: "input" }] } } };
// The pane attaches to its stream a moment after the page is up, and an event fed earlier is lost; feed again until it lands.
async function feedUntil(page, ev, seen) {
for (let i = 0; i < 20; i++) {
await page.evaluate((e) => window.__feed(e), ev);
if (await seen().then(() => true, () => false)) return;
}
await seen();
}
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
@@ -24,8 +34,7 @@ for (const scheme of ["light", "dark"]) {
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate((ev) => window.__feed(ev), band);
await page.getByRole("button", { name: "稍后提醒" }).waitFor();
await feedUntil(page, band, () => page.getByRole("button", { name: "稍后提醒" }).waitFor({ timeout: 500 }));
const at = await page.evaluate(() => {
const s = document.querySelector(".slotted").getBoundingClientRect();
const c = document.querySelector('[aria-label="任务输入"]').getBoundingClientRect();
@@ -45,8 +54,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.activeElement?.blur());
if (shotDir) { await page.mouse.move(width - 5, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-band.png`, clip: { x: 0, y: 760, width, height: 540 } }); }
await page.evaluate((ev) => window.__feed(ev), ask);
await page.waitForSelector('.call[data-k="extension"] .extform');
await feedUntil(page, ask, () => page.waitForSelector('.call[data-k="extension"] .extform', { timeout: 500 }));
const card = await page.evaluate(() => {
const form = document.querySelector(".extform");
return { note: form.querySelector(".why")?.textContent ?? "", tag: document.querySelector('.call[data-k="extension"] .tag')?.textContent ?? "" };
@@ -57,23 +65,33 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("扩展"))?.click());
await page.waitForSelector("#set-mods");
const off = await page.evaluate(() => {
const g = document.getElementById("set-mods");
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
});
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
await page.locator('#set-mods [role="switch"]').click();
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
const on = await page.evaluate(() => {
const g = document.getElementById("set-mods");
const r = g.getBoundingClientRect();
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
});
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
check(`${tag}: the block does not spill sideways`, !on.spill);
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
await page.waitForSelector('[data-action="extensions.add"]');
if (!SHOWN) {
check(`${tag}: the mod settings block is not drawn while the host is hidden`, (await page.locator("#set-mods").count()) === 0);
await page.locator('input[aria-label="搜索设置"]').fill("Claude Code mod");
await page.waitForTimeout(400);
const found = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav *, .prefs-results *")].some((e) => e.children.length === 0 && /Claude Code mod/.test(e.textContent)));
check(`${tag}: settings search does not lead to it either`, !found);
await page.locator('input[aria-label="搜索设置"]').fill("");
} else {
await page.waitForSelector("#set-mods");
const off = await page.evaluate(() => {
const g = document.getElementById("set-mods");
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
});
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
await page.locator('#set-mods [role="switch"]').click();
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
const on = await page.evaluate(() => {
const g = document.getElementById("set-mods");
const r = g.getBoundingClientRect();
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
});
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
check(`${tag}: the block does not spill sideways`, !on.spill);
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
}
if (width === 1280) {
await page.locator('[data-action="extensions.add"]').first().click();
@@ -4,6 +4,8 @@ import { cleanup, render, screen, waitFor, within } from "@testing-library/react
import userEvent from "@testing-library/user-event";
import "./testkit";
import { ModHost } from "./ModHost";
import { MODS_SHOWN } from "./modsgate";
import { SETTINGS } from "./prefsnav";
import { MockPort } from "../port/mock";
import { HttpError } from "../port/port";
import type { ModStatus } from "../port/port";
@@ -14,8 +16,22 @@ afterEach(cleanup);
const row = (name: string) => screen.getByText(name, { selector: "b" }).closest("li") as HTMLElement;
describe("the Claude Code mod block", () => {
it("draws nothing, and asks the kernel nothing, while the host is hidden", () => {
const port = new MockPort();
const read = vi.spyOn(port, "modsSettings");
const { container } = render(<ModHost port={port} />);
expect(container.innerHTML).toBe("");
expect(read).not.toHaveBeenCalled();
});
it("is not offered by settings search or counted in its section while hidden", () => {
const entry = SETTINGS.find((e) => e.anchor === "mods")!;
expect(entry.hidden).toBe(!MODS_SHOWN);
expect(SETTINGS.filter((e) => e.section === "ext" && !e.hidden).includes(entry)).toBe(MODS_SHOWN);
});
it("ships off, says what turning it on means, and every mod says why it is not running", async () => {
render(<ModHost port={new MockPort()} />);
render(<ModHost port={new MockPort()} shown />);
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText(/完全信任.*没有沙盒.*不会被本应用观测到/)).toBeTruthy();
@@ -26,7 +42,7 @@ describe("the Claude Code mod block", () => {
it("turning it on saves once, then lists each state with the words for its typed code", async () => {
const port = new MockPort();
const save = vi.spyOn(port, "saveModsSettings");
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
await userEvent.click(await screen.findByRole("switch", { name: "运行 Claude Code mod" }));
expect(save).toHaveBeenCalledExactlyOnceWith({ enabled: true });
await waitFor(() => expect(within(row("statusline-clock")).getByText(/运行中/)).toBeTruthy());
@@ -42,7 +58,7 @@ describe("the Claude Code mod block", () => {
const port = new MockPort();
const lying: ModStatus[] = [{ plugin: "liar", state: "disabled", code: "mod.crashed", detail: "everything is running fine", restarts: 0 }];
vi.spyOn(port, "mods").mockResolvedValue(lying);
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
expect(await screen.findByText(/进程崩溃了/)).toBeTruthy();
expect(screen.queryByText("everything is running fine")).toBeNull();
});
@@ -50,14 +66,14 @@ describe("the Claude Code mod block", () => {
it("degrades to the kernel's own text for a code it has no words for", async () => {
const port = new MockPort();
vi.spyOn(port, "mods").mockResolvedValue([{ plugin: "odd", state: "disabled", code: "mod.something_new", detail: "the kernel says so", restarts: 0 }]);
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
expect(await screen.findByText("the kernel says so")).toBeTruthy();
});
it("commits the time limit once on Enter and refuses a number out of range without writing", async () => {
const port = new MockPort();
const save = vi.spyOn(port, "saveModsSettings");
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
const field = await screen.findByRole("textbox", { name: "单个钩子的运行时限" });
await userEvent.clear(field);
await userEvent.type(field, "20000{Enter}");
@@ -71,7 +87,7 @@ describe("the Claude Code mod block", () => {
it("goes read-only when this window may not edit, and says so", async () => {
const port = new MockPort();
vi.spyOn(port, "saveModsSettings").mockRejectedValue(new HttpError(403, "no", { code: "mods.editing_disabled", error: "off" }));
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
await userEvent.click(sw);
expect(await screen.findByText("这些设置现在是只读的:这个窗口没有被授予编辑权限。")).toBeTruthy();
@@ -81,7 +97,7 @@ describe("the Claude Code mod block", () => {
it("says so when the settings cannot be read", async () => {
const port = { modsSettings: vi.fn(async () => { throw new Error("down"); }), mods: vi.fn(async () => []) } as unknown as AgentPort;
render(<ModHost port={port} />);
render(<ModHost port={port} shown />);
expect(await screen.findByText("down")).toBeTruthy();
});
});
+6 -1
View File
@@ -5,6 +5,7 @@ import type { AgentPort, ModState, ModStatus, ModsSettings, ModsSettingsEdit } f
import { HttpError } from "../port/port";
import { Group } from "./Group";
import { Switch } from "./Switch";
import { MODS_SHOWN } from "./modsgate";
import "../styles/mods.css";
const BUDGET_MAX = 55000;
@@ -22,7 +23,11 @@ const STATE: Record<ModState, { mark: string; word: string }> = {
* their own with the user's full trust. The switch is the only authorisation to
* run any of them, so it says what that means in plain words, and a client that
* may not edit it sees it read-only rather than failing on a click. */
export function ModHost({ port }: { port: AgentPort }) {
export function ModHost({ port, shown = MODS_SHOWN }: { port: AgentPort; shown?: boolean }) {
return shown ? <ModHostBlock port={port} /> : null;
}
function ModHostBlock({ port }: { port: AgentPort }) {
const [settings, setSettings] = useState<ModsSettings | null>(null);
const [mods, setMods] = useState<ModStatus[] | null>(null);
const [draft, setDraft] = useState("");
@@ -10,7 +10,7 @@ interface Props {
export function SettingsHeading({ section, value, danger }: Props) {
const group = NAV.find(([, items]) => items.some(([id]) => id === section));
const name = group?.[1].find(([id]) => id === section)?.[1] ?? section;
const count = SETTINGS.filter((entry) => entry.section === section).length;
const count = SETTINGS.filter((entry) => entry.section === section && !entry.hidden).length;
return (
<header className="prefs-intro">
+4
View File
@@ -0,0 +1,4 @@
/** The mod host's settings stay out of sight, and out of settings search, until
* the receipt marker and the protocol ruling land (#13694); the kernel refuses
* to turn it on until then. One line to flip. */
export const MODS_SHOWN = false;
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { MODS_SHOWN } from "./modsgate";
import { t } from "../i18n";
/** The settings table of contents: which sections exist, what each is called,
@@ -185,6 +186,8 @@ export interface SettingEntry {
* which is exactly what this replaces. */
scope: SettingScope;
apply: ApplySemantics;
/** Drawn by nothing yet: left out of search and of the section's count. */
hidden?: boolean;
}
// One row per block the settings screen renders, checked both ways against
@@ -200,7 +203,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "session", anchor: "receipt", title: "交付验收卡片", scope: "machine", apply: "immediate", keywords: ["回执", "验收", "验证", "未验证", "receipt"] },
// Written to the user file only, and read by the running turn at its next round.
{ section: "session", anchor: "ask-timeout", title: "提问等待超时", scope: "machine", apply: "immediate", keywords: ["ask", "跳过", "无人回答", "长任务", "超时", "自动继续"] },
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", hidden: !MODS_SHOWN, keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
{ section: "session", anchor: "progress-watch", title: "长时间无进展", scope: "machine", apply: "immediate", keywords: ["暂停", "无进展", "空转", "卡住", "循环", "轮次", "token"] },
{ section: "session", anchor: "changed-files", title: "文件改动", scope: "machine", apply: "immediate", keywords: ["改动", "变更", "文件", "已更改", "药丸", "输入框"] },
{ section: "session", anchor: "turn-narration", title: "回合间叙述", scope: "machine", apply: "immediate", keywords: ["叙述", "总结", "小轮", "工具之间", "详细", "简短", "思路", "监控", "narration"] },
@@ -11,7 +11,7 @@ export function useSettingsSearch(query: string, shown: (id: Section) => boolean
return useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return null;
return SETTINGS.filter((e) => shown(e.section) && (e.anchor !== "graphics" || shell !== null) && settingMatches(e, q));
return SETTINGS.filter((e) => !e.hidden && shown(e.section) && (e.anchor !== "graphics" || shell !== null) && settingMatches(e, q));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, shownKey, shell]);
}
@@ -1,2 +1,2 @@
- 新增:设置里可以打开「运行 Claude Code mod」,列出每个 mod 的状态与原因;安装预览写明完全信任与静态清单;mod 可在输入框上方画一条带。关联 #13694。
- Added: Settings can turn on Run Claude Code mods and lists each mod's state and why; the install preview states full trust and a static listing; a mod can draw a band above the prompt. Refs #13694.
- 新增:mod 的安装预览写明完全信任与静态清单;mod 可在输入框上方画一条带,它的提问会写明来源。关联 #13694。
- Added: A mod's install preview states full trust and a static listing; a mod can draw a band above the prompt, and its questions say whose they are. Refs #13694.