Merge pull request #13764 from esengine/feat/13694-mod-ui-3

feat(studio): Claude Code mods in the window: install preview, the band above the prompt, mod questions (settings block hidden)
This commit is contained in:
YHH authored and GitHub committed 2026-10-18 03:23:05 -07:00
commit edcbc47c06
29 files changed
+724 -10

No files matched your search

+1
View File
@@ -149,6 +149,7 @@ jobs:
node perf/session-move.mjs
node perf/session-ref.mjs
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
+118
View File
@@ -0,0 +1,118 @@
// Claude Code mods in the window: the band a mod draws above the prompt, a question a mod asks, the settings
// block (switch, time limit, each mod's state by code) and the install preview's full-trust listing.
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 : ""}`);
if (!ok) fails.push(name);
};
const band = { kind: "extension_surface", extension: { pluginId: "statusline-clock", surfaceId: "mod-above-prompt", kind: "view", view: { slot: "above_prompt", body: [
{ kind: "row", children: [{ kind: "pip", tone: "ok" }, { kind: "text", value: "statusline-clock", tone: "strong" }, { kind: "text", value: "12:30 · 今天还剩 3 个任务", tone: "dim" }, { kind: "button", label: "稍后提醒", actionId: "snooze:5" }] },
] } } };
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]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 1300 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
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();
return { above: s.bottom <= c.top + 4, spill: s.right > innerWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1 };
});
check(`${tag}: the band stands above the text the person types in`, at.above);
check(`${tag}: the band does not push the page sideways`, !at.spill);
await page.getByRole("button", { name: "稍后提醒" }).focus();
check(`${tag}: its button can be reached from the keyboard`, await page.evaluate(() => document.activeElement?.textContent === "稍后提醒"));
const ring = await page.evaluate(() => {
const b = document.activeElement, rail = document.querySelector(".slotrail");
const cs = getComputedStyle(b);
const room = Math.min(b.getBoundingClientRect().left - rail.getBoundingClientRect().left, rail.getBoundingClientRect().right - b.getBoundingClientRect().right);
return { ring: parseFloat(cs.outlineWidth) + Math.max(0, parseFloat(cs.outlineOffset)), room };
});
check(`${tag}: the focus ring of the band's button is not cut off by the strip it sits in`, ring.room >= ring.ring - 0.5, `${ring.room}px room for a ${ring.ring}px ring`);
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 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 ?? "" };
});
check(`${tag}: a mod's question says whose it is and that answering approves nothing`, card.note.includes("git-guard") && card.note.includes("不会批准任何工具调用或权限") && card.tag.includes("git-guard"), card.note);
if (shotDir && width === 1280) await page.locator('.call[data-k="extension"]').last().screenshot({ path: `${shotDir}/${scheme}-ask.png` });
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('[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();
const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last();
await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard");
await page.locator('[data-action="extensions.inspect"]').first().click();
await page.waitForSelector(".modpreview", { timeout: 10000 });
const prev = await page.evaluate(() => {
const m = document.querySelector(".modpreview");
const text = m.textContent;
return { trust: text.includes("完全信任"), static: text.includes("不是保证"), imports: text.includes("看不见"), unserved: text.includes("本应用不会触发的事件"), hash: !!m.querySelector("[title]") };
});
check(`${tag}: the install preview states full trust, that it is a static listing, hidden imports and unserved events`, prev.trust && prev.static && prev.imports && prev.unserved && prev.hash, JSON.stringify(prev));
if (shotDir) await page.locator(".cand").first().screenshot({ path: `${shotDir}/${scheme}-preview.png` });
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+2
View File
@@ -704,6 +704,8 @@ export const ACTIONS: UIAction[] = [
{ id: "session-ref.tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session-ref.reset", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.reference-match", kind: "view", target: "entity", proof: "interaction" },
{ id: "mods.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mods.budget", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -622,4 +622,11 @@ export const EN_KERNEL: Record<string, string> = {
"该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再移动": "Another process holds this session open (pid {pid} on {host}); end it, then move it",
"它已经在这个项目里了": "It is already in this project",
"这个会话的记录不完整,不能移动;先让它自己恢复": "This conversation's record is incomplete and cannot be moved; let it recover first",
"「运行 Claude Code mod」没有打开,所以没有运行任何 mod": "\"Run Claude Code mods\" is off, so no mod is running",
"这台电脑上找不到能运行 mod 的 Node 运行时": "No Node runtime that can run mods was found on this computer",
"找到的 Node 运行时版本过低,不能运行这个 mod": "The Node runtime found is too old to run this mod",
"这个 mod 的进程崩溃了,正在按退避重新启动": "This mod's process crashed and is being restarted with backoff",
"它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用": "One of its hooks ran past its time budget and the process was ended; after repeats it is disabled",
"它的某个钩子抛出了异常": "One of its hooks threw an exception",
"它用到了本宿主不支持的功能": "It uses something this host does not support",
};
@@ -1314,4 +1314,47 @@ export const EN_SETTINGS: Record<string, string> = {
"移动到项目…": "Move to a project…",
"检查点(回退快照)、后台任务日志和排队中的消息不随会话移动,原件会被移除。": "Checkpoints (rewind snapshots), background-task logs and queued messages do not travel; the originals are removed.",
"对话已经移过去了,但原来的那份没能删除,请手动删除。": "The conversation was moved, but the original could not be removed; please delete it by hand.",
"运行 Claude Code mod": "Run Claude Code mods",
"默认关闭。打开后,已安装的 mod 里的代码会以你的权限在这台电脑上运行(完全信任):没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。关闭时一个 mod 都不会运行。": "Off by default. When on, the code in installed mods runs on this computer with your permissions (full trust): there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. With it off, no mod runs.",
"单个钩子的运行时限": "Time limit for one hook",
"超过这个时间,mod 的进程会被结束。0 表示使用默认的 10 秒,最大 {max} 毫秒。": "Past this time the mod's process is ended. 0 uses the default 10 seconds; the most is {max} ms.",
"毫秒": "ms",
"无法读取 mod 设置。": "The mod settings could not be read.",
"已安装的 mod": "Installed mods",
"未运行": "Not running",
"正在启动": "Starting",
"正在重启": "Restarting",
"已重启 {n} 次": "Restarted {n} times",
"它注册的事件": "Events it registered",
"本应用不会触发这些事件": "Events this app never raises",
"没有安装带 mod 的插件包。": "No installed package carries a mod.",
"Claude Code mod": "Claude Code mods",
"插件包里的 mod 可以在会话的事件上挂钩子。": "A mod in a package can hook the session's events.",
"Claude Code mod 预览": "Claude Code mod preview",
"Claude Code mod · 完全信任": "Claude Code mod · full trust",
"它的代码会在这台电脑上以你的权限运行:没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。只有在设置里打开「运行 Claude Code mod」后才会运行。": "Its code runs on this computer with your permissions: there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. It runs only once \"Run Claude Code mods\" is turned on in Settings.",
"下面是读取代码语法树得到的静态清单,不是保证:imports 是看不见的代码;eval、new Function 和用计算出来的名字访问接口,都会让清单列不全。": "What follows is a static listing read from the code's syntax tree, not a guarantee: imports are code you cannot see, and eval, new Function and reaching the API by a computed name all leave the listing incomplete.",
"未预览": "Not previewed",
"这不等于没问题:读不了的代码按“未预览”处理。": "That does not mean it is fine: code that cannot be read counts as not previewed.",
"它是 TypeScript,本应用不读": "It is TypeScript, which this app does not read",
"文件太大": "The file is too large",
"语法解析失败": "It could not be parsed",
"解析超时": "Parsing timed out",
"不是本宿主支持的模块形式": "It is not a module form this host supports",
"它挂钩的事件": "Events it hooks",
"(没有列出)": "(none listed)",
"名字无法列出的事件": "Events whose names cannot be listed",
"它用到的接口": "API it uses",
"它引入的代码(看不见)": "Code it pulls in (not visible)",
"会让清单列不全的写法": "Constructs that leave the listing incomplete",
"用了 eval": "Uses eval",
"用了 new Function": "Uses new Function",
"用计算出来的名字访问接口": "Reaches the API by a computed name",
"把接口对象交给了别处": "Hands the API object elsewhere",
"把注册函数交给了别处": "Hands the registering function elsewhere",
"把接口对象解构了出来": "Destructures the API object",
"没有找到导出的 register": "No exported register was found",
"本应用不会触发的事件": "Events this app never raises",
"模块": "Module",
"这是插件「{id}」的提问;回答它不会批准任何工具调用或权限。": "This is a question from the plugin \"{id}\"; answering it does not approve any tool call or permission.",
};
+7
View File
@@ -494,6 +494,13 @@ const SAID: Record<string, string> = {
"mods.invalid_budget": "单个钩子的运行时限是 0 到 55000 毫秒,0 表示默认的 10 秒",
"mods.editing_disabled": "这个客户端不能修改 mod 设置",
"mods.withheld": "运行 Claude Code mod 的开关还没有开放,现在不能打开",
"mod.switch_off": "「运行 Claude Code mod」没有打开,所以没有运行任何 mod",
"mod.runtime_missing": "这台电脑上找不到能运行 mod 的 Node 运行时",
"mod.runtime_unsupported": "找到的 Node 运行时版本过低,不能运行这个 mod",
"mod.crashed": "这个 mod 的进程崩溃了,正在按退避重新启动",
"mod.hook_timeout": "它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用",
"mod.hook_threw": "它的某个钩子抛出了异常",
"mod.unsupported": "它用到了本宿主不支持的功能",
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
@@ -1,5 +1,6 @@
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import { mockMods } from "./mock_mods";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
import type { PersonaDraft, PersonaSettings } from "./boundary";
@@ -209,6 +210,22 @@ export class MockBoundary extends MockShell {
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
}
protected modConfig: ModsSettings = { enabled: false, hook_budget_ms: 0, path: "/Users/you/.reasonix/config.toml", available: false, applies: "next_session" };
async modsSettings(): Promise<ModsSettings> {
return { ...this.modConfig };
}
async saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings> {
if (edit.hook_budget_ms !== undefined && (edit.hook_budget_ms < 0 || edit.hook_budget_ms > 55000)) throw new Error("a hook budget is 0 to 55000 milliseconds");
this.modConfig = { ...this.modConfig, enabled: edit.enabled ?? this.modConfig.enabled, hook_budget_ms: edit.hook_budget_ms ?? this.modConfig.hook_budget_ms };
return this.modsSettings();
}
async mods(): Promise<ModStatus[]> {
return mockMods(this.modConfig.enabled);
}
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
async peersSettings(): Promise<PeersSettings> {
@@ -1,4 +1,5 @@
import { download } from "./download";
import { mockModPlan } from "./mock_mods";
import { HttpError, type PluginExport, type PluginInstallRequest, type PluginPackage, type PluginPlan, type ScopeLayer, type SkillCatalog, type SkillEntry, type SkillUpdatePlan } from "./port";
import { MockMarket } from "./mock_market";
@@ -111,6 +112,7 @@ export class MockExtensions extends MockMarket {
// a hook and connects a server is what the confirmation page exists for.
async planPlugin(req: PluginInstallRequest): Promise<PluginPlan> {
const source = req.source;
if (/\bmod\b/i.test(source)) return mockModPlan(source);
return {
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mock",
actions: [
@@ -0,0 +1,40 @@
import type { ModStatus } from "./boundary";
import type { ModPreview, PluginPlan } from "./plugin";
const RUNTIME = { kind: "electron", path: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", version: "v22.12.0" };
/** Four installed mods in the states a person can meet: the list is the same
* with the switch off, but then nothing runs and each says why. */
export function mockMods(enabled: boolean): ModStatus[] {
const names = ["statusline-clock", "git-guard", "prompt-tweaks", "fancy-notes"];
if (!enabled) return names.map((plugin) => ({ plugin, state: "inactive" as const, code: "mod.switch_off", restarts: 0 }));
return [
{ plugin: names[0], state: "running", restarts: 0, runtime: RUNTIME, events: ["session.start", "turn.complete"], unserved: [] },
{ plugin: names[1], state: "disabled", code: "mod.hook_timeout", restarts: 3, runtime: RUNTIME, events: ["tool.check", "tool.after"], unserved: ["tool.rewrite"] },
{ plugin: names[2], state: "restarting", code: "mod.crashed", restarts: 1, runtime: RUNTIME, events: ["prompt.submit"], unserved: [] },
{ plugin: names[3], state: "inactive", code: "mod.runtime_missing", restarts: 0, events: ["turn.complete"], unserved: [] },
];
}
export const MOCK_MOD_PREVIEW: ModPreview = {
plugin: "git-guard", module: "hooks/index.js", sha256: "9f2c41b07ad35e8c61f0aa42d9be3377c0e1145f8a6d2b9c4e7710d3a5b8c6e2", listed: true,
events: [{ name: "tool.check" }, { name: "tool.after" }, { name: "turn.complete" }, { name: "x", dynamic: true }],
api: [{ namespace: "process", method: "run" }, { namespace: "fs", method: "readFile" }, { namespace: "ui", method: "toast" }],
imports: [{ spec: "./lib/rules.js", kind: "static" }, { spec: "minimatch", kind: "require" }],
findings: [{ kind: "computed_member" }],
unserved: ["tool.rewrite"],
};
/** The plan a source naming a mod gets: one package whose action carries the
* preview of its hooks module. */
export function mockModPlan(source: string): PluginPlan {
return {
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mockmod",
actions: [{
kind: "plugin", action: "install_plugin_package", status: "planned", riskLevel: "high", name: "git-guard", version: "1.2.0", source,
target: "~/.reasonix/plugins/git-guard", manifestKind: "claude-code", skills: [], skillCount: 0, commandCount: 0, hookCount: 0,
riskReasons: ["FULL TRUST: ships a Claude Code mod whose code runs on this machine"], mod: MOCK_MOD_PREVIEW,
}],
next: "Review the plan, then install.",
};
}
+4 -1
View File
@@ -47,7 +47,7 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -301,6 +301,9 @@ export interface AgentPort {
resolvePeerMessage(id: string, approve: boolean, remember?: PeerRemember): Promise<void>;
peersSettings(): Promise<PeersSettings>;
savePeersSettings(edit: PeersSettingsEdit): Promise<PeersSettings>;
mods(): Promise<ModStatus[]>;
modsSettings(): Promise<ModsSettings>;
saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings>;
displayCurrency(): Promise<DisplayCurrencySettings>;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
+10 -1
View File
@@ -1,7 +1,7 @@
import { SseShell } from "./sse_shell";
import type { PersonaDraft, PersonaSettings } from "./boundary";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -78,6 +78,15 @@ export class SseBoundary extends SseShell {
savePeersSettings(edit: PeersSettingsEdit) {
return this.post0<PeersSettings>("/peers/settings", edit);
}
async mods() {
return (await this.get<{ mods: ModStatus[] }>("/mods")).mods ?? [];
}
modsSettings() {
return this.get<ModsSettings>("/mods/settings");
}
saveModsSettings(edit: ModsSettingsEdit) {
return this.post0<ModsSettings>("/mods/settings", edit);
}
saveSubagentClaims(optimistic: boolean) {
return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic });
}
+1 -1
View File
@@ -4139,7 +4139,7 @@
.slotrail {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 4px 2px 6px; max-height: 84px; overflow-y: auto;
padding: 4px 5px 6px; max-height: 84px; overflow-y: auto;
}
.slotrail .xview { flex-direction: row; align-items: center; gap: 8px }
@@ -0,0 +1,9 @@
.modlist { margin: 10px 0 0; padding: 0; list-style: none; display: grid; gap: 8px }
.modlist li { display: grid; gap: 2px; padding: 8px 10px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface) }
.modlist .modhead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px }
.modlist b { font-weight: var(--w-med); overflow-wrap: anywhere }
.modlist .modstate { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
.modlist li[data-state="running"] .modstate { color: var(--ok, var(--text)) }
.modlist li[data-state="disabled"] .modstate { color: var(--danger, var(--text)) }
.modlist li[data-state="restarting"] .modstate { color: var(--warn, var(--text)) }
.modlist .ds { overflow-wrap: anywhere }
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState } from "react";
import { ModPreviewBlock } from "./ModPreviewBlock";
import { useEscape } from "./dismiss";
import { current as language, plural, t } from "../i18n";
import { useFileDrop } from "./filedrop";
@@ -310,6 +311,7 @@ export function Candidate({ a }: { a: PluginAction }) {
{r.why && <span className="why">{t(r.why)}</span>}
</div>
))}
{a.mod && <ModPreviewBlock mod={a.mod} />}
{adds.length > 0 && (
<div className="risk">
<span className="lb">{t("将添加")}</span>
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
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";
import type { AgentPort } from "../port/port";
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()} shown />);
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText(/完全信任.*没有沙盒.*不会被本应用观测到/)).toBeTruthy();
await waitFor(() => expect(screen.getAllByText("「运行 Claude Code mod」没有打开,所以没有运行任何 mod")).toHaveLength(4));
expect(within(row("git-guard")).getByText(/未运行/)).toBeTruthy();
});
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} 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());
expect(within(row("git-guard")).getByText(/已停用/)).toBeTruthy();
expect(within(row("git-guard")).getByText(/时间预算.*进程已被结束/)).toBeTruthy();
expect(within(row("git-guard")).getByText("已重启 3 次")).toBeTruthy();
expect(within(row("git-guard")).getByText(/本应用不会触发这些事件.*tool\.rewrite/)).toBeTruthy();
expect(within(row("prompt-tweaks")).getByText(/正在重启/)).toBeTruthy();
expect(within(row("fancy-notes")).getByText(/找不到能运行 mod 的 Node 运行时/)).toBeTruthy();
});
it("words a state by its code, never by what the detail says", async () => {
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} shown />);
expect(await screen.findByText(/进程崩溃了/)).toBeTruthy();
expect(screen.queryByText("everything is running fine")).toBeNull();
});
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} 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} shown />);
const field = await screen.findByRole("textbox", { name: "单个钩子的运行时限" });
await userEvent.clear(field);
await userEvent.type(field, "20000{Enter}");
expect(save).toHaveBeenCalledExactlyOnceWith({ hook_budget_ms: 20000 });
await userEvent.clear(field);
await userEvent.type(field, "99999{Enter}");
expect(save).toHaveBeenCalledTimes(1);
expect(screen.getByText("请输入 0 到 55000 之间的整数。")).toBeTruthy();
});
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} shown />);
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
await userEvent.click(sw);
expect(await screen.findByText("这些设置现在是只读的:这个窗口没有被授予编辑权限。")).toBeTruthy();
expect((sw as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("textbox", { name: "单个钩子的运行时限" }) as HTMLInputElement).disabled).toBe(true);
});
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} shown />);
expect(await screen.findByText("down")).toBeTruthy();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState, type KeyboardEvent } from "react";
import { t } from "../i18n";
import { reason, say } from "../i18n/kernel";
import type { AgentPort, ModState, ModStatus, ModsSettings, ModsSettingsEdit } from "../port/port";
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;
// The words and the mark for each state: a state never rests on colour alone.
const STATE: Record<ModState, { mark: string; word: string }> = {
inactive: { mark: "○", word: "未运行" },
starting: { mark: "◐", word: "正在启动" },
running: { mark: "●", word: "运行中" },
restarting: { mark: "↻", word: "正在重启" },
disabled: { mark: "⊘", word: "已停用" },
};
/** Claude Code mods: modules an installed package ships, run in a process of
* 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, 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("");
const [busy, setBusy] = useState(false);
const [readOnly, setReadOnly] = useState(false);
const [error, setError] = useState("");
const adopt = (s: ModsSettings) => {
setSettings(s);
setDraft(String(s.hook_budget_ms));
};
useEffect(() => {
let alive = true;
port.modsSettings().then((s) => alive && adopt(s)).catch((e) => alive && setError(reason(e)));
port.mods().then((m) => alive && setMods(m)).catch(() => alive && setMods([]));
return () => {
alive = false;
};
}, [port]);
const save = async (edit: ModsSettingsEdit) => {
setBusy(true);
setError("");
try {
adopt(await port.saveModsSettings(edit));
setMods(await port.mods());
} catch (e) {
if (e instanceof HttpError && e.reason?.code === "mods.editing_disabled") setReadOnly(true);
setError(reason(e));
} finally {
setBusy(false);
}
};
const commit = () => {
if (!settings) return;
const n = Number(draft);
if (!Number.isInteger(n) || n < 0 || n > BUDGET_MAX) return setError(t("请输入 {min} 到 {max} 之间的整数。", { min: 0, max: BUDGET_MAX }));
if (n !== settings.hook_budget_ms) void save({ hook_budget_ms: n });
};
const locked = busy || readOnly;
const body = !settings ? (
<div className="empty">{error || t("无法读取 mod 设置。")}</div>
) : (
<div className="box">
<div className="lrow">
<span className="tx">
<span className="lb">{t("运行 Claude Code mod")}</span>
<span className="ds">{t("默认关闭。打开后,已安装的 mod 里的代码会以你的权限在这台电脑上运行(完全信任):没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。关闭时一个 mod 都不会运行。")}</span>
</span>
<Switch data-action="mods.enabled" on={settings.enabled} busy={locked} label={t("运行 Claude Code mod")} onClick={() => void save({ enabled: !settings.enabled })} />
</div>
<div className="lrow threshold-row">
<span className="tx">
<span className="lb">{t("单个钩子的运行时限")}</span>
<span className="ds">{t("超过这个时间,mod 的进程会被结束。0 表示使用默认的 10 秒,最大 {max} 毫秒。", { max: BUDGET_MAX })}</span>
</span>
<div className="threshold-control">
<span className="unit-field">
<input type="text" inputMode="numeric" aria-label={t("单个钩子的运行时限")} data-action-keydown="mods.budget" data-action-change="mods.budget"
value={draft} disabled={locked}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, ""))}
onBlur={commit}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") e.currentTarget.blur(); }} />
<i>{t("毫秒")}</i>
</span>
</div>
</div>
{readOnly && <div className="peernote" role="status">{t("这些设置现在是只读的:这个窗口没有被授予编辑权限。")}</div>}
{error && <div className="why" role="alert">{error}</div>}
<ul className="modlist" aria-label={t("已安装的 mod")}>
{(mods ?? []).map((m) => (
<li key={m.plugin} data-state={m.state}>
<div className="modhead">
<b>{m.plugin}</b>
<span className="modstate"><span aria-hidden="true">{STATE[m.state].mark}</span> {t(STATE[m.state].word)}</span>
</div>
{m.code && <div className="ds">{say({ code: m.code, error: m.detail })}</div>}
{m.restarts > 0 && <div className="ds">{t("已重启 {n} 次", { n: m.restarts })}</div>}
{!!m.events?.length && <div className="ds">{t("它注册的事件")}:{m.events.join("、")}</div>}
{!!m.unserved?.length && <div className="ds">{t("本应用不会触发这些事件")}:{m.unserved.join("、")}</div>}
</li>
))}
{mods && mods.length === 0 && <li className="ds">{t("没有安装带 mod 的插件包。")}</li>}
</ul>
</div>
);
return <Group id="mods" title={t("Claude Code mod")} hint={t("插件包里的 mod 可以在会话的事件上挂钩子。")}>{body}</Group>;
}
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { ModPreviewBlock } from "./ModPreviewBlock";
import { Candidate } from "./AddPlugin";
import type { ModPreview, PluginAction } from "../port/plugin";
afterEach(cleanup);
const listed: ModPreview = {
plugin: "git-guard", module: "hooks/index.js", sha256: "0123456789abcdef0123456789abcdef", listed: true,
events: [{ name: "tool.check" }, { name: "x", dynamic: true }],
api: [{ namespace: "process", method: "run" }, { namespace: "ui", dynamic: true }],
imports: [{ spec: "./lib.js", kind: "static" }, { spec: "left-pad", kind: "require" }],
findings: [{ kind: "eval" }, { kind: "computed_member" }],
unserved: ["tool.rewrite"],
};
describe("the mod install preview", () => {
it("states full trust, and that the listing is static and not a guarantee", () => {
render(<ModPreviewBlock mod={listed} />);
expect(screen.getByText("Claude Code mod · 完全信任")).toBeTruthy();
expect(screen.getByText(/没有沙盒,不受网络出口治理.*不会被本应用观测到/)).toBeTruthy();
expect(screen.getByText(/静态清单,不是保证.*imports 是看不见的代码.*eval、new Function/)).toBeTruthy();
});
it("lists events, API, hidden imports, listing-defeating constructs and events never raised", () => {
render(<ModPreviewBlock mod={listed} />);
expect(screen.getByText("tool.check")).toBeTruthy();
expect(screen.getByText(/\$\.process\.run, \$\.…/)).toBeTruthy();
expect(screen.getByText("import ./lib.js, require left-pad")).toBeTruthy();
expect(screen.getByText("用了 eval、用计算出来的名字访问接口")).toBeTruthy();
expect(screen.getByText("tool.rewrite")).toBeTruthy();
expect(screen.getByText("名字无法列出的事件")).toBeTruthy();
});
it("calls a module it could not read not previewed, never clean", () => {
render(<ModPreviewBlock mod={{ ...listed, listed: false, reason: "too_large", events: [], api: [], imports: [], findings: [], unserved: [] }} />);
expect(screen.getByText("未预览")).toBeTruthy();
expect(screen.getByText("文件太大")).toBeTruthy();
expect(screen.getByText(/这不等于没问题/)).toBeTruthy();
expect(screen.queryByText("它挂钩的事件")).toBeNull();
});
it("shows a reason it has no words for as itself rather than as nothing", () => {
render(<ModPreviewBlock mod={{ ...listed, listed: false, reason: "brand_new_reason", events: [], api: [], imports: [], findings: [], unserved: [] }} />);
expect(screen.getByText("brand_new_reason")).toBeTruthy();
});
it("pins the exact bytes the module listing was read from", () => {
render(<ModPreviewBlock mod={listed} />);
expect(screen.getByText("hooks/index.js · 0123456789ab").getAttribute("title")).toBe(listed.sha256);
});
it("appears under the candidate that carries a mod, and not under one without", () => {
const withMod = { kind: "plugin", name: "git-guard", mod: listed } as unknown as PluginAction;
const { rerender } = render(<Candidate a={withMod} />);
expect(screen.getByLabelText("Claude Code mod 预览")).toBeTruthy();
rerender(<Candidate a={{ kind: "plugin", name: "plain" } as unknown as PluginAction} />);
expect(screen.queryByLabelText("Claude Code mod 预览")).toBeNull();
});
});
@@ -0,0 +1,74 @@
import { t } from "../i18n";
import type { ModPreview } from "../port/plugin";
// Keyed by the kernel's reason and kind. A key with no entry shows the key itself
// rather than nothing: an unlisted module must never read as a clean one.
const NOT_LISTED: Record<string, string> = {
typescript: "它是 TypeScript,本应用不读",
too_large: "文件太大",
unparseable: "语法解析失败",
timeout: "解析超时",
unsupported_module: "不是本宿主支持的模块形式",
};
const FINDING: Record<string, string> = {
eval: "用了 eval",
function_ctor: "用了 new Function",
computed_member: "用计算出来的名字访问接口",
api_escapes: "把接口对象交给了别处",
on_escapes: "把注册函数交给了别处",
destructured_api: "把接口对象解构了出来",
no_register: "没有找到导出的 register",
};
const IMPORT_KIND: Record<string, string> = { static: "import", require: "require", dynamic: "import()" };
const list = (xs: string[]) => xs.join("、");
/** What the install preview says about a Claude Code mod: it runs on this
* machine with the user's full trust, and the listing below is read from the
* module's syntax tree. It is a static list and not a guarantee, and the block
* says so in those words; a module that could not be read is "not previewed". */
export function ModPreviewBlock({ mod }: { mod: ModPreview }) {
const dynamic = mod.events.filter((e) => e.dynamic).length;
const calls = mod.api.map((c) => (c.dynamic ? "$.…" : c.method ? `$.${c.namespace}.${c.method}` : `$.${c.namespace}`));
return (
<section className="modpreview" aria-label={t("Claude Code mod 预览")}>
<div className="risk" data-kind="shell">
<span className="lb">{t("Claude Code mod · 完全信任")}</span>
<span className="why">{t("它的代码会在这台电脑上以你的权限运行:没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。只有在设置里打开「运行 Claude Code mod」后才会运行。")}</span>
</div>
<p className="why">{t("下面是读取代码语法树得到的静态清单,不是保证:imports 是看不见的代码;eval、new Function 和用计算出来的名字访问接口,都会让清单列不全。")}</p>
{!mod.listed ? (
<div className="risk" data-kind="secret">
<span className="lb">{t("未预览")}</span>
<span className="dt">{t(NOT_LISTED[mod.reason ?? ""] ?? mod.reason ?? "")}</span>
<span className="why">{t("这不等于没问题:读不了的代码按“未预览”处理。")}</span>
</div>
) : (
<>
<div className="risk"><span className="lb">{t("它挂钩的事件")}</span><span className="dt">{mod.events.filter((e) => !e.dynamic).map((e) => e.name).join(", ") || t("(没有列出)")}</span></div>
{dynamic > 0 && <div className="risk" data-kind="secret"><span className="lb">{t("名字无法列出的事件")}</span><span className="dt">{dynamic}</span></div>}
{calls.length > 0 && <div className="risk"><span className="lb">{t("它用到的接口")}</span><span className="dt">{calls.join(", ")}</span></div>}
{mod.imports.length > 0 && (
<div className="risk" data-kind="secret">
<span className="lb">{t("它引入的代码(看不见)")}</span>
<span className="dt">{mod.imports.map((i) => `${IMPORT_KIND[i.kind] ?? i.kind} ${i.spec}`).join(", ")}</span>
</div>
)}
{mod.findings.length > 0 && (
<div className="risk" data-kind="secret">
<span className="lb">{t("会让清单列不全的写法")}</span>
<span className="dt">{list(mod.findings.map((f) => t(FINDING[f.kind] ?? f.kind)))}</span>
</div>
)}
{mod.unserved.length > 0 && <div className="risk"><span className="lb">{t("本应用不会触发的事件")}</span><span className="dt">{mod.unserved.join(", ")}</span></div>}
</>
)}
<div className="risk">
<span className="lb">{t("模块")}</span>
<span className="dt" title={mod.sha256}>{mod.module} · {mod.sha256.slice(0, 12)}</span>
</div>
</section>
);
}
@@ -35,6 +35,7 @@ import { BrowserTools } from "./BrowserTools";
import { AskTimeout } from "./AskTimeout";
import { SessionRef } from "./SessionRef";
import { TraceExport } from "./TraceExport";
import { ModHost } from "./ModHost";
import { WebAccess } from "./WebAccess";
import { ProgressWatch } from "./ProgressWatch";
import { DisplaySettings } from "./DisplaySettings";
@@ -623,6 +624,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
>
{reload.note}
</Group>
<ModHost port={port} />
<Group id="plugins"
title={t("插件包")}
now={packages.length ? t("{n} 个", { n: packages.length }) : undefined}
@@ -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">
@@ -144,6 +144,7 @@ export function ExtensionCard({ ext, onInvoke, onSubmit }: Props) {
{ext.form && (
<div className="extform" data-sealed={sent ? "" : undefined} aria-busy={pending}>
<div className="why">{t("这是插件「{id}」的提问;回答它不会批准任何工具调用或权限。", { id: ext.pluginId })}</div>
{ext.form.message && <div className="exttext">{ext.form.message}</div>}
{ext.form.fields.map((f) => (
<Field key={f.key} f={f} value={values[f.key]} sealed={sent || pending} onChange={(v) => set(f.key, v)} />
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "./testkit";
import { SlottedView } from "./SlottedView";
import { placement } from "./slots";
import { ExtensionCard } from "./cards/ExtensionCard";
import type { ExtensionSurface, ExtensionViewNode } from "../port/wire";
afterEach(cleanup);
const band = (body: ExtensionViewNode[], slot = "above_prompt"): ExtensionSurface => ({
pluginId: "statusline-clock", surfaceId: "mod-above-prompt", kind: "view", view: { slot, body },
});
describe("the band a mod draws above the prompt", () => {
it("stands above the composer when it asks for above_prompt, and in the rail for a name nobody knows", () => {
expect(placement(band([], "above_prompt"), {})).toBe("composer-trailing");
expect(placement(band([], "composer-trailing"), {})).toBe("composer-trailing");
expect(placement(band([], "somewhere_odd"), {})).toBe("sidebar");
expect(placement(band([], "above_prompt"), { "statusline-clock:mod-above-prompt": "sidebar" })).toBe("sidebar");
});
it("sends back exactly the actionId its button carries, and nothing else", async () => {
const onAction = vi.fn();
const ext = band([
{ kind: "text", value: "12:30" },
{ kind: "button", label: "Snooze", actionId: "snooze:5" },
]);
render(<SlottedView ext={ext} onAction={onAction} onMove={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Snooze" }));
expect(onAction).toHaveBeenCalledTimes(1);
expect(onAction.mock.calls[0]).toEqual(["snooze:5"]);
});
it("gives the page no way to name an action the surface did not carry", async () => {
const onAction = vi.fn();
const ext = band([
{ kind: "text", value: "approve_all", actionId: "approve_all" },
{ kind: "button", label: "No action" },
]);
render(<SlottedView ext={ext} onAction={onAction} onMove={() => {}} />);
fireEvent.click(await screen.findByText("approve_all"));
fireEvent.click(await screen.findByText("No action"));
expect(onAction.mock.calls).toEqual([]);
});
it("names the plugin on the control that moves it", () => {
render(<SlottedView ext={band([{ kind: "text", value: "x" }])} onAction={() => {}} onMove={() => {}} />);
expect(screen.getByRole("button", { name: "移动 statusline-clock 的界面" })).toBeTruthy();
});
});
describe("a question a mod asks", () => {
const ask: ExtensionSurface = {
pluginId: "git-guard", surfaceId: "mod-ask", sessionId: "s1", generation: 1, kind: "form",
form: { title: "git-guard asks", message: "Delete the stale branch?", fields: [{ key: "answer", label: "Answer", kind: "input" }] },
};
it("says whose question it is and that answering it approves nothing", () => {
render(<ExtensionCard ext={ask} onSubmit={async () => {}} />);
expect(screen.getByText("这是插件「git-guard」的提问;回答它不会批准任何工具调用或权限。")).toBeTruthy();
expect(screen.getByText("扩展 · git-guard")).toBeTruthy();
});
it("answers only through the extension's own submit, never through an approval", () => {
const submit = vi.fn(async () => {});
render(<ExtensionCard ext={ask} onSubmit={submit} />);
fireEvent.change(screen.getByRole("textbox", { name: "Answer" }), { target: { value: "yes" } });
fireEvent.click(document.querySelector('[data-action="extensions.submit"]')!);
expect(submit).toHaveBeenCalledExactlyOnceWith("git-guard", "mod-ask", { answer: "yes" });
expect(document.querySelector('[data-action^="approval"], [data-action*="allow"]')).toBeNull();
});
});
+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;
@@ -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,
@@ -195,6 +196,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
@@ -212,6 +215,7 @@ export const SETTINGS: SettingEntry[] = [
// 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: "session", anchor: "session-ref", title: "引用历史会话的长度", scope: "machine", apply: "immediate", keywords: ["引用", "历史会话", "过往会话", "上下文", "长度", "token", "session"] },
{ 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"] },
@@ -71,7 +71,7 @@ it("finds extension blocks by the displayed section name without mounting them",
const reads = [...calls];
await userEvent.type(find(), "Extension");
const hits = screen.getAllByRole<HTMLButtonElement>("option");
expect(hits.map((hit) => hit.dataset.target)).toEqual(SETTINGS.filter((entry) => entry.section === "ext").map((entry) => entry.anchor));
expect(hits.map((hit) => hit.dataset.target)).toEqual(SETTINGS.filter((entry) => entry.section === "ext" && !entry.hidden).map((entry) => entry.anchor));
expect(hits.every((hit) => hit.querySelector(".where")?.textContent === "Extension")).toBe(true);
expect(calls).toEqual(reads);
expect(block("plugins")).toBeNull();
+5 -1
View File
@@ -12,6 +12,9 @@ export const SLOTS: { id: string; label: string }[] = [
export const DEFAULT_SLOT = "sidebar";
// Names the protocol uses for places this frontend draws under its own name.
const ALIASES: Record<string, string> = { above_prompt: "composer-trailing" };
/** key is how a placement is addressed: one extension's two surfaces can sit
* in different places, so the surface is the unit, not the plugin. */
export function key(ext: ExtensionSurface): string {
@@ -22,6 +25,7 @@ export function key(ext: ExtensionSurface): string {
* extension asked for, and where this frontend puts things it was told nothing
* about. A name none of us knows resolves to the default rather than nowhere. */
export function placement(ext: ExtensionSurface, assigned: Record<string, string>): string {
const candidate = assigned[key(ext)] || ext.view?.slot || DEFAULT_SLOT;
const asked = ext.view?.slot ?? "";
const candidate = assigned[key(ext)] || ALIASES[asked] || asked || DEFAULT_SLOT;
return SLOTS.some((s) => s.id === candidate) ? candidate : DEFAULT_SLOT;
}
@@ -32,8 +32,9 @@ const implementations: ReactComponentImplementation[] = [
createComponentImplementation({ name: "Divider", schema: z.object({}).strict() }, () => <hr className="xv-hr" />),
createComponentImplementation({ name: "ExtensionButton", schema: z.object({ label: z.string(), actionId: z.string() }).strict() }, ({ props }) => {
const { onAction, inputs, disabled, actionDisabled } = useContext(SurfaceContext);
if (inputs) return <Button data-action="surface.action" data-target={props.actionId} disabled={disabled||actionDisabled} onClick={()=>onAction?.(props.actionId)}>{props.label}</Button>;
return <button className="act" data-action="extensions.invoke" onClick={() => onAction?.(props.actionId)}>{props.label}</button>;
const named = props.actionId !== "";
if (inputs) return <Button data-action="surface.action" data-target={props.actionId} disabled={disabled||actionDisabled||!named} onClick={()=>onAction?.(props.actionId)}>{props.label}</Button>;
return <button className="act" data-action="extensions.invoke" disabled={!named} onClick={() => onAction?.(props.actionId)}>{props.label}</button>;
}),
createComponentImplementation({ name: "Field", schema: z.object({ node: z.custom<ExtensionViewNode>(), value: fieldValue }).strict() }, ({ props }) => {
const { disabled, inputs } = useContext(SurfaceContext);
@@ -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]);
}
@@ -0,0 +1,2 @@
- 新增: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.