dsh-mise
用 mise 管理本机多语言运行时的 DeepSeek Harness 插件:设置页看已装 / 可用版本,按目录(.mise.toml 驱动)安装、切换、卸载,并给 agent 暴露九个工具。
- Host 半(
src/index.ts+src/mise.ts,编到dist/):所有mise调用、JSON 解析、写入闸门,以及插件自己的/miseRPC 通道上的一组端点。 - Client 半(
src/client.ts,编到dist/client.js):注册在settings.section上一个「mise」页(order 48),数据全部走已认证的/miseRPC 通道,不自建 HTTP 路由。
源码是 TypeScript,Harness 载入的是编译产物:package.json 的 main / exports 指向 dist/,主机侧只 import 纯 ESM JavaScript,浏览器侧只拿到单文件 bundle,所以运行时不带 TS 加载器、也不依赖任何 @deepseek-ai/* 运行时包。
装了什么
| 能力 | 位置 |
|---|---|
| 一张表看全部:当前生效 / 已装几个版本 / PATH 命中的那份 | 设置 → mise 的「工具链」表;数据来自 mise_status + mise_system |
| 镜像自带的工具链折叠成一行 | 表下一行「系统自带 · N」,点开是名字 / 版本 / 路径(只读);默认收起 |
| 装了版本就能下钻管理 | 该行工具名前的 ▸ 展开版本子表:每行一个版本,带状态、安装路径、启用 / 卸载;镜像自带的那份也在里面,标 系统、只读 |
| 已装版本 / 可用远程版本 / latest | 行内「已装」写 2 个版本 这样的个数,展开子表看是哪几个;远程版本走顶部搜索框,或 mise_versions |
| 版本号上有更新提示 | 表里「当前」列的版本号后面跟一个虚线小标 ↑1.8.2(有新版本时才有),悬停给出当前版本与配置要求,点它直接打开该工具的版本列表;数据来自 mise/updates → mise outdated --inactive --bump --json |
| 配置要求但未安装的版本(会直接报错的那些) | 表里该行「当前」列标「未安装」,行尾给「安装 x.y.z」,表头给「按配置安装全部」 |
安装 tool@version |
面板「安装」按钮;mise_install |
切换版本(写项目 mise.toml) |
面板「启用」;mise_use(工具仍可 scope=global) |
| 卸载某个已装版本 | 面板版本行上的「卸载」(点两次确认);mise_uninstall |
| 一个框搜索 | 输入工具名(node)直接列可安装版本;输入 工具名@前缀(pnpm@12)只列该前缀下的版本;输入用途(javascript runtime)自动退回 registry 搜索 |
| 安装结果可信 | mise_install 的 installed 是从 mise 自己的 ✓ tool@version 行解析出来的真实安装结果,请求放在 requested;版本不存在时 ok:false 且 installed: [],不会把请求当结果回报 |
| 状态里带激活信息 | mise_status 的 activation 给出 shims 目录与当前可用的命令,agent 装完能判断 shell 里是否直接可用 |
| 安装/切换/清理时看实时进度 | 面板里的实时输出框(标题 + 已用秒数 + mise 自己的输出,每 0.7s 刷新) |
| 清理没被任何配置引用的版本(先预览再执行) | mise_prune(默认 dryRun);面板里只标 未使用,逐个卸载 |
| registry 搜工具名 | 面板搜索框(只读) |
agent 工具
九个工具,覆盖"看清现状 → 找版本 → 装 → 钉住 → 清理"整条链路:
| 工具 | 作用 |
|---|---|
mise_status |
mise 版本/路径、目标目录、目录链上的配置、在效版本、缺失版本,以及命令行激活状态(装完能不能在 shell 里直接用) |
mise_versions |
给 tool 看已装/在效/latest/远程版本;给 query 搜 registry |
mise_system |
只读扫描 PATH 上的开发工具:命中的路径与版本、mise 是否另有受管版本(fromMise / managed),以及跳过 mise 自己的 shim / 安装目录后镜像自带那份(systemPath / systemVersion) |
mise_install |
装 tool@version;background: true 时立刻返回任务 id,用 mise_progress 轮询 |
mise_use |
写进项目 mise.toml 或全局配置(未装会先装) |
mise_prune |
清理没人用的版本,默认 dryRun: true 只预览 |
mise_uninstall |
删掉指定版本 |
mise_activate |
开关命令行激活(把 shims 放宿主 PATH 最前),返回新的激活快照 |
mise_progress |
读一条写入命令的实时进度:在跑什么、多久、最近的输出行、是否结束;结束后附带完整结果 |
进度为什么是"任务 + 轮询":工具调用是请求/响应,没法流式推。所以每次写入都记成一条任务,面板轮询正在跑的那条,agent 则用 background: true 起步、再用 mise_progress 按 id 取自己的那条(不带 id 就是最近一次运行,包括面板里点的)。大工具不再把一次调用卡满 15 分钟。
配置
行配置在 cordis.patch.yml(装好后也可由插件设置表单写):
| 字段 | 默认 | 含义 |
|---|---|---|
miseBin |
'' |
留空则依次探测:PATH 上的 mise → ~/.local/bin/mise → /usr/local/bin/mise → /opt/homebrew/bin/mise → ~/.local/share/mise/bin/mise。GUI 拉起的 Harness 继承的 PATH 里通常没有 ~/.local/bin,所以按序探测而不是直接报「找不到」 |
cwd |
'' |
面板与工具的默认目录;留空 = Host 进程 cwd。面板不提供切换控件,改目录改这里(面板顶部会显示当前用的是哪个) |
env |
{} |
每个 mise 子进程的额外环境变量,如 MISE_DATA_DIR / MISE_TRUSTED_CONFIG_PATHS |
timeoutMs |
20000 |
读命令(ls / ls-remote / registry)超时 |
longTimeoutMs |
900000 |
install / use / prune 超时 |
writeMode |
auto |
readonly:面板与工具都拒绝写入;ask:每次写入工具调用走 DSH 审批通道(没有审批方时拒绝,安全方向);auto:直接执行 |
remoteLimit |
30 |
一次返回多少个远程版本 |
registryLimit |
40 |
registry 搜索返回条数 |
activate |
true |
加载时把 /shims 放到宿主 PATH 最前,让工具命令在任何 shell 里都能用;false 则不动 PATH(面板开关只影响本次进程) |
ask 依赖 DSH 的审批通道;本 profile 的审批被设为「从不询问」,所以这里取 auto,要收紧改 ask 或 readonly。
写入边界
- 只动 mise 自己的 install 目录与配置文件;
prune固定带--tools,不碰 mise 的 config 追踪表。 - 卸载只接受显式
tool@version,空参数直接报mise/bad-request;仍被mise.toml引用的版本也能卸(那是你的选择,下一个mise install会装回来),只想清无引用的用prune。 - 卸载 = 删文件 + 删掉配置里指向它的那条请求(2026-10-22 起)。
mise uninstall只删文件,留着的请求会让那一行以「未安装」的样子继续挂在表里——这正是「卸载了怎么还在列表里」。所以卸载之后立刻补一步mise unuse:--path指向mise ls --json里source.path报出的那个文件(绝不猜「第一个加载的配置」),--no-prune保证只改配置不删别的版本;还有别的版本装着时只删字面匹配该版本的那条,一条不剩时把该文件里这个工具的请求全部删掉。答案里的forgot就是被删掉的请求清单。 -
反过来,一条从来没有安装过的请求(
mise.toml里写了但没装过,或早先卸载留下的)在面板上是一个「未安装」行,行内「从配置移除」= 单独的mise unuse,同样只认明确路径。 -
真 prune 前一定先跑一次 dry run,返回的
removed就是那次预览里列出的版本,不是猜的。 - 面板的写按钮 = 用户点击,所以不经审批;agent 的写工具走
writeMode。 - 环境合成:子进程继承 Host 环境,再叠
MISE_YES=1、NO_COLOR=1、TERM=dumb与env,输出是纯文本,不携带光标控制。 - 不设
MISE_AUTO_INSTALL=true:install 只发生在显式调用时。
文件
src/index.ts Host 半:apply / 九个工具 / /mise 端点 / writeMode 闸门
src/mise.ts mise 命令层:探测 binary、spawn、JSON 解析、prune 解析
src/client.ts Client 半:settings.section 页面(纯 React,无 Harness 包依赖)
dist/*.js|*.d.ts 构建产物(Harness 实际载入的就是这里;随仓库保留)
test/host.ts 假 ctx 冒烟测试:跑的是 dist/index.js
test/client.ts 假 window/document + 真 React 渲染:跑的是 dist/client.js
tsconfig.json 编译配置(strict、NodeNext、声明文件)
tsconfig.test.json 测试与源码一起做类型检查(noEmit)
cordis.patch.yml 一行 insert + 默认配置
locale/{en,zh}.json Plugin Manager / 设置里显示的标题与描述
icon.svg 图标(自绘)
.agent-check/motion 动效的离线核验:从 dist 抽插件自己的 CSS 拼一张静态页,用 headless Chromium 查转场(见「动效」一节)
构建与自测
npm install # 只装开发期依赖:typescript、@types/*、@deepseek-ai 类型包、react(测试渲染用)
npm run build # tsc -p tsconfig.json → dist/,再跑 scripts/finish-client.mjs
npm run typecheck # 源码 + 测试一起检查(不产出文件;测试 import 的是 dist/,所以要先 build)
npm test # 自动先 build(pretest),再跑 test/host.ts 与 test/client.ts
npm run check # build + typecheck + 两套测试,一条命令的交付闸门
npm run legacy:drop # 宿主重启后删掉根目录遗留的 index.js / mise.js / client.js
构建了,但浏览器还是旧代码
宿主不读 dist/——它在启动时按当时的 package.json 的 exports["./client"] 解析出这份 bundle 的绝对路径,之后只 stat 那一个文件(dsh-client-hmr 每 500 ms 一次),内容变了才 republish 新 rev 给浏览器。于是:
exports若在宿主启动之后才改(本插件 2026-10-22 06:55 才从仓库根的client.js切到dist/client.js),运行中的宿主仍盯着旧路径;此后再怎么npm run build,它 stat 的文件没变,浏览器拿到的永远是旧字节,页面表现就像「改动没生效」。- 临时办法:把刚构建的产物同步到宿主盯着的那份(
cp dist/client.js client.js),半秒内 rev 就变、浏览器经 SSE 热替换。 - 彻底办法:重启
dsh web,或在 插件管理 里关掉再打开这个插件,让它按现在的exports重新解析;之后根目录的client.js/index.js/mise.js(TS 之前的遗留产物)就是纯重复,跑一次npm run legacy:drop删掉,dist/是唯一真源(脚本会先确认package.json已经指向dist/,再删)。TS 迁移时留的.legacy-backup/备份已在 2026-10-22 删除:其中index.js/mise.js与根目录那两份逐字节相同,client.js/host.mjs已被src/与test/host.ts取代。 - 构建脚本会顺手把
dist/client.jscp到根目录那份client.js(只要它还在),所以重启前每次npm run build都能让运行中的宿主拿到新字节;legacy:drop之后这一步自动失效。 - 自查手段:带登录 cookie
curl /plugins/events,看@sovlookup/dsh-mise那条的rev,再与sha1(mtimeMs, ctimeMs, size)的 12 位前缀对照;touch候选文件看 rev 变不变,就知道宿主盯的是哪一个。
开发期依赖只提供类型,运行时代码里没有一条 import 指向 @deepseek-ai/*:
@deepseek-ai/cordis、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-client-connection(+ 传递的dsh-llm)给Context、ToolDefinition、ToolOutputDefinition、PreToolDecision、HostConnectionHandle这些接缝提供真实声明,版本钉在与运行中的 Harness 相同的0.2.1-rc.2(cordis4.0.5)。- Client 半反过来:它刻意不引 Harness 包,
window.__ModuleLoader__、工厂的require、ctx.slots/ctx.locale/ctx.connection都在文件里就地声明,所以 Harness 升级不会因为某个被删掉的导出把这一页打挂。 react/react-test-renderer只为test/client.ts真的把页面渲染一遍。
构建产物要一起提交:Harness 不会替你编译,dist/ 缺失就是插件加载失败。
重新安装 / 更新:
dsh plugin --profile web add github:SOVLOOKUP/dsh-mise # 本地改动则用 dsh plugin --profile web add /workspace/research/dsh-mise
插件代码改动要 npm run build 后再重启 Harness 才载入新模块(package.json 的入口与浏览器 bundle 都是 dist/ 里的文件);只改 cordis.patch.yml 的配置由 HMR 即时重组。
激活:让工具命令真的能用
插件加载时把 /shims 放到宿主进程 PATH 的最前面(等价于 mise activate 的核心效果,但不需要改 shell 配置)。之后 Host 派生的任何 shell 里 bun、以及以后用 mise 装的工具,都能直接调用,且是"按目录切版本"的:项目目录里用 mise.toml 指的那版,没有受管版本时自动落回系统那份。
- 生效范围:本 Host 进程及其子进程。外部终端不受影响。
- 谁赢:mise 有受管版本就用它;没有就用系统那份(shims 会回落),所以不会把命令弄没。
- 面板上不再有开关(顶部那块按需求去掉了):要临时改这一进程的状态用工具
mise_activate,或把配置项activate改成false再重启;下次启动是否自动开启由activate决定(默认true)。
持久化:mise 的状态不放 /tmp
这个镜像把 XDG_CONFIG_HOME、XDG_DATA_HOME 指到 /tmp,而 /tmp 重启即清空 —— mise 的全局配置(/mise/config.toml)和安装目录本来都会落在那里。插件每次加载时执行一次修正:
- 持久副本放在
$HOME下:~/.config/mise、~/.local/share/mise(本机即/workspace/...); - 把
/tmp那三个默认路径改成指向它们的符号链接(目录为空或不存在时才动,非空则只在面板里提示drift,绝不删数据); - 因此裸
mise(不带任何MISE_*环境变量)和面板看到的是同一份状态。
配置项里的 env.MISE_DATA_DIR 与这条链接指向同一个目录,两者一致。
系统里已装的工具(只读)
面板会扫 PATH 上常见的 18 个工具名(node / npm / pnpm / yarn / bun / deno / corepack / python3 / python / go / rustc / cargo / java / ruby / php / uv / poetry / jq),拿到命中的路径与版本(版本只留数字:各工具的 --version 花样不同,Python 3.13.5 / jq-1.7 / v24.22.0 / go version go1.22.2 … 一律取其中的版本号,不把程序名和 v 带进表格)。只读,不做任何改变;结果缓存 60 秒。命令扫两遍:第一遍是 PATH 上先命中的那个(开了激活就是 mise 的 shim,版本探不出来也不去探——它会答 mise 自己的横幅),第二遍跳过 mise 的 shim 与安装目录,拿到镜像真正自带的那份路径与版本。两遍都要,否则 shims 排在前面时「系统那份是哪一版」就永远查不到。
扫描结果只用来补「这一行在 PATH 上是谁」,不额外造行:只有当某个名字本来就在本目录的 mise 配置里(active / installed / missing 任一处有它)时,才在「当前」列落一个回落版本、并在版本子表里列出镜像自带的那份(标 系统,只读)。这个标记不再挂在工具行上(2026-10-22 按用户要求):是 mise 那份还是镜像那份,是某个版本的事实,就写在那一行版本旁边。镜像里自带、但和本项目无关的命令(corepack、python3 这类)不会单独成行——它们既不是项目工具,mise ls-remote 也查不到(corepack 根本不在 registry 里,报 corepack not found in mise tool registry),列出来只会是一个点开永远 0 个版本、也没法装的行。既然行只来自 mise,一个什么都没配、也没装受管版本的目录就是一张空表,这时显示一句「本目录的 mise 配置里还没有任何工具」,而不是一直挂在「读取中…」上——出口是表上方那个搜索框。
它们不消失,只是折起来:表下面一行「系统自带 · N」(N = 扫描到、但既不在 mise 配置里也不是 mise 装的数量),点开是名字 / 版本 / 路径三列,默认收起。名字可点,点了打开该工具的版本列表(和顶部搜索框输工具名是同一个动作)——corepack 这类不在 registry 的命令会得到「没有可显示的版本」,这也是它不该占一行正表的原因。折叠状态只存在这一次页面里,不写 localStorage:它不是偏好,只是「看一眼」的动作。
这样存在的原因:mise 没有「接管已有二进制」的功能,预装的东西它既看不见也管不了(mise which pnpm 会直接说 pnpm is not a mise bin)。旧写法 pnpm = "system" 在 2026.10.17 已废弃(WARN deprecated [system_tool_version],提示改用 MISE_DISABLE_TOOLS),而且它不会把系统那份接进来——mise which 只会报 No executable found for configured tool。所以面板只能诚实标出「当前是系统那份」,并说明「想管就得装一份受管版本」(agent 工具 mise_system 仍会返回完整的 18 个候选扫描结果,需要「机器上到底有什么」时问它)。
受管版本要真的顶掉系统那份,前提是 mise 的 shims 排在系统目录之前(mise activate,或把 /shims 加到 PATH 前面);实测 shims 在前时项目内命中的是受管版本,不在任何 mise 配置覆盖的目录里时则会落回系统那份,不会让命令凭空消失。
一个工具多个版本:版本子表
正表的「已装」列只写个数:2 个版本,不是一个光秃秃的数字,也不列是哪几个。哪几个、各自什么状态、装在哪、能对它做什么,全在子表里:
- 触发:该行工具名前的
▸(悬停提示「展开版本子表 · jq」),再点一次▾收起。只要这一行知道任何一个版本就有这个箭头(盘上 1 个也算;配置要了但没装的也算;镜像自带的那份也算)——行里已经不写版本了,子表是唯一能看到「是哪个版本」的地方,路径和卸载也在里面。 - 正表一行按钮都没有(2026-10-22 按用户要求):面板上的每个动作都是对着某个版本做的(装它、启用它、卸它),所以动作只在子表里出现,行本身只读。唯一的例外是表头上方那颗「按配置安装全部」——它一次处理所有缺失版本,不属于任何一行。
- 子表内容:一行一个版本,四列
版本 | 状态 | 路径 | 操作。已装的一行状态是生效/未使用(未使用来自清理预览),路径是 mise 的安装目录(installPath,超长省略号、悬停看全文),操作只有两个:启用(mise use tool@version写本目录mise.toml,生效中的版本没有这一项)和「卸载 → 确认卸载」两步。 - 配置要了、盘上没有的版本也在这张表里(
未安装chip,路径列是—):操作是安装 1.8.2(mise install)和从配置移除(mise unuse,删掉声明它的那一条请求;悬停「路径」列的—看是哪个配置文件要的)。这条以前在行内,正表去掉操作列后搬到这里——不搬就没有入口了,因为这种行没有任何已装版本。 - 四列宽度是定死的(
76px-0.7fr / 92px / 2fr / 176px):.dsmise-sub-row每一行各是一个 grid,auto列会按各自内容算宽度,整张表会参差不齐。 - 为什么只留个数(2026-10-22 按用户要求;单位
个版本是后补的):一列 chip 会把每一行撑到它最重那个工具的高度,而绝大多数行只有一个版本、已装和当前是同一个版本号——重复的是最宽的那部分。个数让整列对齐、行高一致,代价是「具体是哪几个」要多一次点击。但光一个数字读不出它是什么:2挂在已装表头下,眼睛会先把它当成某种数量的代号,所以数字后面带上单位(2 个版本/1 个版本,英文2 versions/1 version,0 是淡色0 个版本),列宽也跟着从 56px 放到 84px。 - 因此行内不再有版本 chip,也就不再有点 chip 打开版本列表这条路:要装新版本直接在顶部搜索框输工具名(那里是版本列表,每个远程版本都有「安装」)。子表里也不再放「远程版本」按钮——它回答的是「有哪些版本可装」,和这一列(盘上已有的版本)不是同一个问题。
- 镜像自带的那份也占一行:
版本 | 系统(虚线 chip)| 路径 |空操作列。它不能「启用」也不能「卸载」——mise 没有接管已有二进制的功能,所以这一行的操作列就是空的,不摆一个按不了或者按了会被拒的按钮。项目里没装受管版本时,这行就是「jq到底跑的是哪一版」的答案;装了受管版本时,它在同一张表里排在下面,一眼能看出自己装的和镜像给的不是同一个版本。 - 展开状态跟着页面走,不落盘;刷新(重新读状态)不会把它折回去,因为它是同一棵树里的状态。
表头不再有「清理」
清理 N 按钮去掉了(2026-10-22 按用户要求)。剩下的分工:
- 读还在:每次刷新仍跑一次
mise prune --tools --dry-run,因为未使用这个判断只有 mise 自己给得出来,子表的「状态」列靠它。它是个纯读(dry run),几毫秒。 - 写不在面板里:批量真删改的是 mise 的安装目录,而这个按钮原来就跟「按配置安装全部」并排挂在表格正上方——扫描列表时最容易误点的地方。要批量清理用 agent 工具
mise_prune(默认dryRun: true,先看再删),面板里按版本卸载就去子表点「卸载」,两步确认、只动那一个版本。 - 之所以留着
未使用标记而不同时删掉这个读:没有它,「哪个版本是可以扔的」得自己比对配置和安装目录。
一个搜索框
安装区只有一个输入框,位置在工具表格上方,没有标题行也没有说明行(输入的三种含义写在占位符里:工具名或用途,如 node / pnpm@12 / javascript runtime)。输入工具名就直接是该工具的版本列表;输入 工具名@前缀(pnpm@12)就是 mise 自己的写法(等价 mise ls-remote pnpm@12,实测收窄到 12.0.0–12.14.0 共 30 个),@ 后半截作为 prefix 传给 Host 的 versions 端点,工具名仍旧只取前一半——「已装」列答的是这个工具的版本,前缀只筛远程列表。只有版本形状的后缀才算前缀(必须数字开头),所以 npm:@scope/pkg 这类自带 @ 的工具名、以及还没打完的 node@ 都不会被拆开。收窄过的列表在卡片头上挂一枚可点的 @12 药丸(悬停「只看这个前缀的版本,点一下看全部」):点它按整工具重列,否则窄列表就是个死胡同。名字对不上任何工具时(mise 对未知工具返回空列表而不是报错,所以判据是「空」而非「异常」)自动把它当用途去 registry 搜,结果是「registry 里的匹配」,点「查版本」进入版本列表。全都搜不到就只留一句提示,不留空列表。版本卡片里既没有第二个筛选框,也没有「刷新」:装完或启用后卡片自己重读一次,卡片头上只剩一个「收起」。
安装进度
通道是一次请求一次响应,推不了流:宿主把当前写命令的输出留在内存里(startProgress / tracked),面板在写操作进行时每 0.7 秒调 progress 取一次,取到的是去掉 ANSI 转义之后的最近若干行。
关键一点是 \r 不能被当成换行。mise 每秒好几次用 \r 重画同一行状态(进度条、downloading 3.0/36.6 MB),如果按字面 split 成多行,面板就会刷屏。所以每一行先算一个「键」——去掉数字、小数点、█░▓▒ 这些会变的部分和多余空白,剩下的才是这行的身份;同键的新行就地替换旧行,位置不变。行长上限 24 条,跨 chunk 的半个行会先攒着等分隔符。结果是安装过程中那一块始终只有几行,数字在原地跳动。写命令结束即停止轮询,结果显示在常规提示区。install / use / uninstall / prune 都走这条路径,agent 调用对应工具时同样记录。
有新版本的小提示
版本号后面的虚线小标 ↑1.8.2 只在 mise 说「有更新的版本」时出现,标的是最新那版的号,位置只有一个:「当前」列里正在生效的那个版本号右边(也就是说,只有能升级的那一版才配得上它)。悬停给出完整一句:最新版、当前生效的版本、配置要的版本(有配置的话),以及「点击查看可安装的版本」;点它直接打开这个工具的版本列表,那里每个远程版本都有「安装」。
几处判断:
- 比较交给 mise,不自己写:
mise outdated --inactive --bump --json才知道每个工具怎么排版本(1.10>1.9,temurin-21.0.1+12不是数字元组),手写字符串或点分段比较一定会在某些工具上错。 - 带
--bump:不带时只和「配置允许的最新版」比,配置里钉死jq = "1.8.2"就永远不算过期,提示会一直不出现——那正好是用户最想知道的情形。带上之后比的是「全局最新发布」,配置范围写进悬停说明里,不偷偷替用户改配置。 - 带
--inactive:没被任何配置请求、但已经装着的工具也要检查,否则表里有它、提示却没有。 - 只有生效那一版能升级,判决不成立就整条丢掉:
--inactive让 mise 把「盘上留着、没在用」的旧版也拿来比。一个工具可以同时握着两版:bun 1.4.0(未使用)加生效中的1.4.3,mise 回的是current: 1.4.0, latest: 1.4.3——这是对旧那版的判决,而旧版不会「升级」(对它来说答案是卸载,子表里那颗按钮就是)。所以这条判决的current必须等于这个工具当前生效的版本(ls --current --json报的那一版)才画,否则整条丢掉:既不画在「当前」列(那会变成劝人升级到一个已经装着的版本1.4.3 ↑1.4.3),也不画在子表里旧版那一行(那是把同一句噪音挪个位置,2026-10-22 用户第二次报「bun 已经安装最新版本了,1.4.0 就不需要提示升级了」)。同理,什么都没生效的工具没有可升级的版本号,也不画。 current为空的行丢掉:那是「配置要、但什么都没装」的情况,表里已经标「未安装」并给了安装按钮,没有版本号可以挂提示;current === latest的行也丢掉(mise 一般不会给,给了也不是提示)。- 它是独立一次读,不挡表格:这是唯一会出网的读(每个工具去问自己的发布源)。面板
刷新时和status/system/ 清理预览并行发出,回来才把提示补上;失败(离线、发布源不通)就当没有提示,不弹错、不画一个假的「已是最新」。 - 小标是虚线描边:虚线读起来是「附注」,不会和
未安装/未使用这类状态混淆。
导航行图标
settings.section 的注册契约只有 id / 顺序 / 文字,没有图标位;DSH 设置外壳按 section id 写死映射,不认识的 id 一律画通用齿轮。因此导航行里的图标由 label 自己画:label 返回一个 React 节点(外壳直接渲染它),节点里是平台图标集中的 IconCodeOutlineMedium(16px,与其它行齿轮同尺寸同网格),并在 layout effect 里把宿主那个齿轮 display:none 掉。这套做法与 dsh-vaultwarden 相同。平台图标取不到时退回手绘的双箭头 SVG,不会报错。想换字形只改 platformIcon() 里那一个导出名即可(可选:Code / WorkspaceTree / SlidersTwo / Gauge / Inspect / Skill)。
动效
面板是一层普通 DOM 加一份注入的 CSS,所以动效也只有 CSS:没有动画库,没有 JS 逐帧,只用 transform / opacity / 一处 height。
- 曲线和时长:宿主主题里没有任何缓动或时长 token(它的 CSS 里一条
cubic-bezier都没有),所以这页自己定一套,挂在.dsmise上:--dsmise-ease-out: cubic-bezier(0.23,1,0.32,1)(进 / 出)、--dsmise-ease-in-out: cubic-bezier(0.77,0,0.175,1)(屏内移动)、--dsmise-fast: 120ms、--dsmise-base: 180ms、--dsmise-unfold: 200ms、--dsmise-collapse: 220ms。最后一个同时是 JS 里的COLLAPSE_MS:CSS 负责动画,它只负责「等它折完再从树里摘掉」。浏览器内置的ease-out在 200ms 这个量级太软,读不出「有意图」。 - 整页进场:数据读完之前那儿只有一行
读取中…,读完是一整屏表格——同一帧把两屏内容怼上来是跳变。所以.dsmise > *的每一块各自opacity 0→1+translateY(4px)→0,180ms,第二个大块(搜索框下面那张表)延迟 50ms。它是挂载动画:刷新是同一批节点重渲染,不会重播。 - 折叠(版本子表 / 系统自带):唯一用
height的地方——手风琴没有transform的等价物。JS 先量scrollHeight(元素被裁到 0 高时它仍然回答内容高度),把像素高度交给 wrapper 让转场从那儿走,到位后放回auto,这样折子开着的时候新冒出来的版本行也撑得开;收起时先把当前高度钉住、下一帧再归零,折完 220ms 才卸载——所以下面那一行是被推回去的,不是瞬移。没有用0fr → 1fr那套纯 CSS 技巧:它要求栅格高度确定,而这一页的高度全由内容撑;另外padding会变成行的最小内容高度,折起来时会漏出 12px 一条边(在 headless 里量到过)。 - 箭头:
▸画成一个 SVG,展开时rotate(90deg),160ms。字形换成▾是没法缓动的。 - 提示条 / 安装进度块:它们就是
.dsmise > *,进场走同一条 4px 位移;进度块左边多一个 6px 呼吸点(opacity1→.25,1.4s),因为日志还没打出字的时候,「在跑」和「卡死」长得一模一样。 - 按钮:hover 和
kind变色 120ms;按下scale(.97)100ms——这是「按到了」的反馈,不是装饰(命令要等一会儿才回话)。按压不做 hover 位移(触屏会误触发)。 prefers-reduced-motion:不是全部关掉,而是留淡入、去掉位移——进场变成 120ms 纯opacity,折叠、箭头、按压、呼吸点一律瞬时。- 折着的时候内容
visibility: hidden+pointer-events: none:折到一半的按钮既点不到、也不会被 tab 到。
结构性的部分(类名、is-open、折完就卸载)在 test/client.ts 里;「这条曲线好不好看」渲染测试测不出来,见下面「验证状态」那条的实际验证方式。
自测
npm test # 构建 + 两套冒烟测试
node --experimental-strip-types test/host.ts # 只跑 Host 半(需先 npm run build)
node --experimental-strip-types test/client.ts # 只跑 Client 半(需先 npm run build)
test/host.ts:假 ctx(inject/effect/on/tools.register/connection.rpc.handle),验通道挂载、方法矩阵、readonly 拒绝、审批闸门、进度行合并、后台任务轮询、updates的筛选。test/client.ts:假window.__ModuleLoader__与document,模块工厂拿到真的 React,注册进settings.section之后用react-test-renderer真的渲染一遍——断言它只走/mise通道、首读是mise/status、并且把宿主返回的数据画进表格(jq/1.7.1),以及有更新时版本号后面那个↑最新版。
真实链路(面板 404/405 那类问题只能这样抓到):把 profile 复制一份到临时 DSH_HOME、node_modules 指回原处,起第二个实例在别的端口,然后用它的 ?token= 登录 URL 拿 cookie,对 /mise/status 发 /api 同样的信封,再在浏览器里点开 设置 → mise 看渲染。
自测不造替身二进制
test/host.ts 不用假的 mise 可执行脚本,也不 stub spawn:需要「已知答案」的地方,把决定抽成纯函数(narrowOutdated、planUnuse)直接喂一段 mise 的输出原文;需要真跑的地方就用真 mise(cwd: /tmp,只读端点 + 一个注定失败的 install 用来验证后台任务记录),既不装东西也不改任何 mise.toml。想测「
帧不该变成 12 行」这种只有下载时才出现的行为,靠真机安装采样(见下面的验证状态),不在测试里伪装。
验证状态
- Host 层:用假 ctx 跑通全部端点(status / versions / registry / install / use / prune 预览与真跑 / readonly 拒绝 / 未知端点),并用真实 mise 2026.10.17 跑过
jq/node的安装、切换、清理。 - 线上注册:九个工具在 Tool 目录里可见;
settings.section里出现dsh-mise / mise / order 48 / active,说明浏览器已加载并执行了 client 模块。 - 激活:真机在干净
bash -c里command -v bun→/workspace/.local/share/mise/shims/bun、bun --version→ 1.4.3,而node/npm/pnpm仍是/usr/local/bin(没被劫持);面板卡片实测 on → 关闭 → off → 开启 → on。删掉三条 /tmp 符号链接再加载插件,链接自动重建、裸mise ls又能看到 bun。 - 系统扫描:真机验过两种标记——同样的名字由 mise 装上并排在 PATH 前时显示「受管」(2026-10-22 按用户要求去掉了这个 chip:是 mise 那份属于正常情况,标出来只是噪音),落回镜像自带那份时给出它的版本(2026-10-22 起
系统这个标记从工具行搬进版本子表,见「版本子表」一节)。顺带修掉一个自查出来的 bug:mise ls --installed少了--json会输出表格,导致受管版本判断恒为 0。2026-10-22 起扫描只给本目录配置里已有的工具补标记,corepack这类「镜像里有、项目里没有」的命令不再单独成行(它们不在 mise registry,点开永远是 0 个远程版本)。 - 安装进度:真机装
bun@1.4.2(36.6 MB)全程采样行数最大 6 行、终态 6 行,没有刷屏。真机装node@26.12.1时抓到实时行(downloading 3.0/62.9 MB · 116 kB/s),面板标题显示mise install node@26.12.1 · 已用 45s;该次安装在临时数据目录里进行,已连目录一起删除,未影响本机安装。 - 面板:顶部只有一行「工具链管理」;「刷新」搬到下面安装行、紧挨「查找」右边,一个「刷新」= 拉状态 + 扫 PATH + 跑一次清理预览(表里的
·未使用标记就来自它),不需要第二下。主体第一行是唯一的输入框(没有标题行、没有说明行)+「查找」+「刷新」,接着才是表工具 | 当前 | 已装(没有操作列:动作都在版本子表里),没有分区标题和说明行,表下也没有文字——未使用版本只体现在子表的未使用标记上,只有错误才出一行红字;工具行上没有标记(哪份来自镜像写进子表那一行)、工具名下面那行 PATH 路径已去掉(悬停工具名看 title)、「当前」列只留版本号(哪个配置文件要它,悬停版本号看 title;有新版在它后面跟一个虚线小标)、「已装」列写N 个版本(是 0 就画一个淡色的0 个版本)、「启用」(只写当前目录mise.toml;DSH 的活都在 /workspace 下,项目级就够,面板不提供全局写入);表头只在有东西可处理时出现按钮,现在只剩「按配置安装全部」(有缺失时)。输入框在表格上方:输入工具名列版本,输入用途退回 registry 搜索。写入结果的通知条能点 ✕ 关掉,成功的那条 8 秒后自己消失,失败的要读一眼才关。截图见assets/panel.png。 - 卸载后清配置(2026-10-22):
planUnuse用mise ls --json的三种行做单元测试——同文件里还留着别的已装版本时只删字面匹配该版本的那条(bun@1.4.3,bun = "1"这种前缀请求留下)、一条不剩时删该文件里这个工具的整条请求、没碰的工具不动、listing 读不出来时什么都不删;test/client.ts断言「未安装」行上出现「从配置移除」按钮,且点击后打的是mise/unuse且带对声明文件路径。真机侧:mise unuse pnpm --path ~/.config/mise/config.toml --no-prune删掉了指向已卸载 pnpm 的悬空请求(mise removed: pnpm from ~/.config/mise/config.toml),配置里只剩bun = "1.4.3"。 - 未验证:深浅色两套主题下的配色差异(只看了浅色)。
- 有更新提示(2026-10-22):筛选逻辑抽成纯函数
narrowOutdated(outdated --json 的原文),test/host.ts直接喂三种行(落后的、current: null的、已是最新的),断言只留第一条并保住requested;面板侧断言↑1.8.2画在当前版本号后面、且「未安装」那种没有版本号的行不画提示;真机mise outdated --inactive --bump --json在本目录给出bun 1.4.2 → 1.4.3,updates()9.9 ms(mise 自己缓存了远程版本),未在浏览器里目视检查深浅色两套配色。 - 去掉「受管」chip 与子表的「远程版本」按钮(2026-10-22):
test/client.ts断言树里没有tagMise却有tagSystem(系统那份仍要标),并断言下钻后的子表里既没有remoteVersions也有uninstall+setProject(两个真作用于版本的动作还在)。 - 已装只留个数 + 去掉「清理」按钮(2026-10-22):
test/client.ts断言「已装」列画的是2, 1两个数字而不是 chip、预置了一条「未使用」的清理预览却断言页面上一个danger按钮都没有(按钮真的没了,标记还在),并断言每个有版本的行都能下钻、行内一个按版本按钮都不留(uninstallHint计数:未下钻 0 → 下钻后 2)。同一张静态预览页重截了一次,确认数字列对齐、行高一致。 - 系统自带折叠 / 版本子表(2026-10-22):
test/client.ts用真渲染点开了两处折叠——表下「系统自带 · N」展开后列出corepack的版本与/usr/local/bin/corepack(且确认里面没有卸载按钮、不是一张子表),jq行的▸展开后列出 1.7.1 / 1.6.0 两行、四列齐全、两个版本都能卸载,再点▾收回去;同一次渲染里断言「只有多版本行才有箭头」(drillOpen按钮数 = 1)和「行内一颗按版本按钮都没有」(uninstallHint计数:未下钻 0 → 下钻后 2)。另外用 react-test-renderer 的树 + 插件自己的 CSS 拼了一张静态预览页,headless Chromium 截了两态(默认态、下钻 + 展开态)目视核对列宽、省略号与箭头方向,未在真实设置页里截图。 - 顺着上一条顺手修掉两处与文档不符的实现(都是「扫描不造行」这条约定的落点):表格把
mise_system扫到的corepack之类命令也造了行——test/client.ts那条断言此前靠微任务时序侥幸通过,现在真的管住了;行只来自 mise 之后,空目录会永远停在「读取中…」,补了空态文案「本目录的 mise 配置里还没有任何工具」并加了断言。 - 正表不再有「操作」列(2026-10-22 按用户要求「外面的操作不要了,有里面子表的操作就行」):表头与每行只剩
工具 | 当前 | 已装三列,行内一颗按钮都没有;启用/卸载照旧只在版本子表里。原本挂在「未安装」行上的安装 x.y.z与从配置移除也搬进了子表——这种行没有已装版本,不搬就没有入口,所以下钻条件从「盘上有版本」放宽成「盘上有版本 / 配置要了没装 / 镜像自带那份」三者任一。表头上方唯一保留的按钮是「按配置安装全部」(用户选择保留,它是批量的,不属于任何一行)。test/client.ts断言三件事:正表行内dsmise-btn数为 0、首屏树里没有colActions、只缺版本的jq行下钻后子表里同时有install 1.8.2与dropRequest。顺手把子表列宽从auto改成定值(每行各是一个 grid,auto会按各自内容算宽度,整张表参差不齐):用 react-test-renderer 的树拼静态页在 headless Chromium 里量过——四列宽度在所有行上都是 137 / 92 / 392 / 176,scrollWidth === clientWidth无溢出,正表行高 35–37px。 - 「已装」列写单位(2026-10-22 按用户要求「应该是 x 个版本,而不是只显示数字」):数字后面补
个版本(英文version/versions按单复数走),0 也写成淡色的0 个版本;.dsmise-tr第三列从56px放到84px,否则99 个版本会被挤掉。test/client.ts的断言从「画的是2, 1」改成「画的是2 installedTotal, 1 installedTotalOne」——locale 在测试里是回显 key 的桩,这样同时钉住了「数字带单位」和「单版本走单数 key」两件事。 - 更新提示只挂在生效那一版(2026-10-22 分两轮定):第一轮用户报「我安装的就是最新版,怎么还提示升级」,真机
mise outdated --inactive --bump --json给出pnpm current: 12.13.0, latest: 12.14.0——盘上留着 12.13.0、生效的是 12.14.0,判决说的是旧那版,按工具名挂就画成了12.14.0 ↑12.14.0;当时改成「按current找版本」,判决跑到子表里 12.13.0 那一行。第二轮用户看着 bun 再报一次(bun 1.4.0未使用 +1.4.3生效 +current: 1.4.0, latest: 1.4.3),说明换个位置只是把噪音挪走:旧版不会升级,对它的回答是卸载。现在tableRows用status.active里生效那版的号去过滤判决,current对不上就整条丢掉,子表里也不画(那里挂UpdateBadge的代码一起删了);正例仍在——生效中的jq 1.7.1落后时提示照旧画在「当前」列(↑1.8.2那条断言)。test/client.ts的复现改成 bun 形状(1.4.0未生效 +1.4.3生效 + 那条判决):断言首屏没有↑1.4.3、下钻后两版都在树里但整棵树一个↑都没有。 - 动效(2026-10-22 按用户要求「加一点好看的动画」):渲染测试测不了曲线,所以用插件自己的 CSS 拼了一张静态页,在 headless Chromium 里查转场对象而不是掐表——
getAnimations()里有height/200ms、opacity/160ms、箭头上的transform转场,finish()之后量到折叠块 0(收起)→ 60(展开)→ 0(折回),箭头算出的矩阵是rotate(90deg);.dsmise > *量到dsmise-rise/180ms,第二块延迟0.05s。headless 的虚拟时间会把转场冻在起点(先前几次「怎么量都是 0」就是这个原因),所以判据只能是「有没有这条转场 + 终点对不对」,中间帧得在真浏览器里看。test/client.ts那条「收起后子表消失」的断言也跟着改成两步:先摘is-open(closing starts the fold),等 300ms 再断言节点真的没了(closing drops the sub-table once it has folded)。 - 搜索框认
工具名@前缀(2026-10-22 按用户要求「提示还需要提示 pnpm@12 这种带版本号的搜法」):占位符改成工具名或用途,如 node / pnpm@12 / javascript runtime(英文同),并真的支持这个写法而不是只写在提示里——splitSpec只把「数字开头的后缀」当前缀拆出来(npm:@scope/pkg与半截的node@原样保留),拆出的prefix走 Host 的versions端点(那里本来就拼成tool@prefix交给mise ls-remote),工具名仍是前一半;registry 回落也只用工具名查。卡片头上多一枚可点药丸@12,点掉即回到整工具列表,装完/启用后的自动重读会带着原来的前缀。test/client.ts造了一个pnpm@12的输入:断言mise/versions收到{tool:'pnpm', prefix:'12'}、占位符是两个语言各自都带pnpm@12、卡片头出现dsmise-prefix与@12,再点掉药丸断言下一次调用prefix是空串。真机侧mise ls-remote --json pnpm@12返回 30 个版本(12.0.0–12.14.0)。