Skip to content

Latest commit

 

History

History
1285 lines (1100 loc) · 122 KB

external-plugin-guide.md

File metadata and controls

1285 lines (1100 loc) · 122 KB

外部插件接入指南:基于 dsh-better-sidebar 实现新页面

面向 消费插件开发者:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。

适用版本:v0.4.0+(ctx.betterSidebar 服务);声明式设置 v0.4.1+;text/number 设置行 v0.11.0+;badge/生命周期/定向打开/插件设置/版本探测 v0.12.0+;select 设置行(settingSelect)与外链认领(urlTarget)v0.13.0+;统一 @deepseek-ai/cordis 类型基底 v0.15.2+;路由前缀安全解析(hostRouteUrl)v0.25.0+。当前版本 v0.28.0 正式版(13 条 DSH peer 下限 ^0.2.1-rc.1,仅支持 DSH 0.2.1-rc.1+;0.2.1-alpha.5 请固定 v0.28.0-alpha.0,0.2.0-rc.2 请固定 v0.26.1(v0.27.0 尚未发布),0.2.0-rc.1 请用 v0.24.1,0.1.7 线请用 v0.22.1——caret 范围跨 minor 不成立,^0.1.7-rc.1 会被 0.2.0 宿主的启动预检静默禁用)。v0.19.0 移除了自绘右侧面板与自由窗口(见 §0、§11);自 v0.20.0 开发线起(注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1)插件移除了自带的终端(宿主 0.1.6 的 ui-sidebar-terminal 取代,见 §4.4)、移除了终端固定(pin),并在 0.1.7 上把浏览器视图与只读文件预览整体让给宿主(ui-sidebar-browser / ui-sidebar-documentpreview,见 §4.4、§5.4)、收敛了外链接管(见 §4.1)、重写了设置接入面(SettingsForms,见 §8.2)、给文件树加了实时刷新(见 §10);同时删除了轮尾产物行接管(DSH 0.1.6 把 conversation.chat.turnTail 从 chain 改成只能追加的 list,替换语义不复存在)。 正式版发布提醒:v0.28.0 即将接管插件 npm latest,而宿主 npm latest 仍为 0.2.0-rc.2,不能据此判断兼容。旧 rc.2 宿主必须固定插件 v0.26.1;旧 alpha.5 固定 v0.28.0-alpha.0。用户已接受此标签分离,发布 workflow 不变。 本次 alpha.5 → rc.1 上游完整树差异 355 项全部为 package.json、源码零变化,因此不改功能实现;伴随依赖为 Cordis peer ^4.0.5 / dev 4.0.5、group 1.0.5、schemastery ~3.18.5、include 1.0.10。RC 验证单独记录于 适配记录,不以已发布 alpha 的测试数字替代。 权威代码:src/client/service.ts(服务实现)、src/client/builtins/(内置 6 tab + 3 viewer 参考实现)、lib/types/client/service.d.ts(类型声明)。 alpha.5 已删除 @deepseek-ai/dsh-invariants 与上游 /invariant 子路径;本插件同步移除 invariant companion,不提供替代注册服务。HTTP/WS 准入遵循宿主 Connection(见 §6.2)。 当前目录语义:文件 / Git / 终端与新建 sidechat 跟随会话 working_directory。live 读 ctx.workingDirectory.get(session);cold 通过 ctx.sessionQuery.observeSession 的 all projections 读取并在 finally 释放 lease。linked worktree 仍按 base 目录记账;新 sidechat seed 尾部盖章当前 cwd,防止继承历史目录覆盖它。 alpha.5 的 TerminalBlockLabels 要求 commandLine(line);两个渲染消费者与全部 21 份词典同步。宿主兼容性优先读 package.json#engines.dsh,缺席才读 DSH peers;本插件没有该字段,仍用 13 条 peers(第三方 dsh.plugin.json 不参与)。 仓库开发规则(硬约束 / CI / 发版)见 AGENTS.md。


0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起)

从 v0.19.0-alpha.0 起,右列完全属于 DSH:你的 tab 渲染在 DSH 自己的右侧栏里(ctx.sidebarRight / ctx.sidebarRightTabs),插件把每个 TabDescriptor 注册成原生 tab 类型(kind = descriptor.id)+ 一个原生 tab 体。插件自己只保留底部工作台(分栏树、会话内持久化;其中不再有终端——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码没有影响——仍然只调用 ctx.betterSidebar:

  • registerTab / registerFileViewer 签名不变;
  • openTab / openFile 默认落到原生右侧栏;新增可选 OpenTabSeed.target('right' 默认 / 'bottom' 落插件的底部工作台 / 'side' 落原生栏的第二个格,见下表「在侧边打开」);
  • updateTab / closeTab / activateTab 认识原生 tab id(插件为每个原生 tab 维护一条合成 SidebarTab 记录,tab.meta / tab.path 的写入照旧生效);activateTab 对原生 tab 经宿主 ISidebarRight.focus(dsh ≥ 0.1.5)聚焦 tab 及其所在 pane——多实例外部插件 tab(无 (kind, 地址) 身份可重开)因此也能被程序化唤起到前台。
  • 新增可选 OpenTabSeed.reveal: false(只放置、不聚焦,后台激活用)与配套的 clearUnread(type, sessionId)(消除「有新页面」红点),两者均 v0.25.0+。

行为差异(写在这里以免踩坑):

事项 说明
生命周期回调 原生面的「打开」不区分新建/聚焦,因此 onOpen 每次打开都触发(onActivate 仅在插件自己的底部工作台里触发)。程序化 closeTab 与 activateTab 在原生 tab 上正常工作:关闭走宿主 per-session 面,回调拿到完整 tab(含 meta);激活经 ISidebarRight.focus 聚焦 tab 与其 pane。例外:用户直接点原生 tab 上的 × 关闭不会触发 onClose(宿主不广播该事件,插件在 tab 体卸载时丢弃记录)
去重 原生按 (kind, 地址) 去重:有 createTab 的类型每次新开一个 tab(sidechat / diff),其余聚焦已有 tab;dedupeKey 的自定义语义不参与原生面
布局持久化 原生栏的布局只在内存(刷新后回到折叠默认),插件自己的底部工作台仍然持久化
跨会话打开 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放
内置类型接管 插件的 editor 类型以 extension 优先级认领 dsh-resource://file/**(压过内置 ui-sidebar-documentpreview 的 text 预览——即 fallback 带),并接管内置 files 页面 kind(openTab('files') 打开插件的文件树);插件卸载/禁用时内置实现自动复位。但认领是有选择的:宿主自己的文档预览已经覆盖的格式(表格 / PDF / 图片 / Office,清单见 §5.4)由 editor.canOpen 主动拒绝,地址交回内置 text 档
文件树实时刷新(v0.21.1+) 插件接管了内置 files 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 /sidebar/ws/fs-watch socket:客户端上报已展开的目录集,宿主侧按目录 fs.watch(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 fs.tree 同一套词法解析
编辑器预览自动刷新(#855) 模型在会话里改完文件后,正在看的那个预览自己刷新,不必再手点刷新或重开文件。信号是插件自有的会话事件增量流 changes.ops(宿主 src/index.ts 路由),每会话一个共享轮询器(N 个编辑器 tab 每 tick 仍只有 1 个请求,全部隐藏即停;叠加口径:Git 页的会话透镜有它自己的轮询器(primed 之后改用 wait 长轮询等宿主事件,不再是固定间隔重放整窗),两者同开时该会话每 tick 至多 2 个 changes.ops 请求,「1 个」只算编辑器这一份),不新增 fs.watch / fsRead 指纹轮询。只有 write / edit 结算成功且路径命中本页文件的那些调用会触发重载(走既有的 fsRead/load 路径),未命中的文件零重读;dirty 草稿与编辑态一律跳过(脏态优先)。第三方 viewer 要保住自己的草稿就必须如实上报 onToolbarState.dirty / mode,见 §5.2
地址里的行号(#826) 宿主的 markdown 链接语法只认 GitHub 的 #L131 片段,path:131 形式(编译器诊断 / grep 的写法,模型输出很常见)会被当成文件名的一部分送进地址。插件的 editor 类型在把地址转成合成记录时把尾部行号规格(:行 / :起-止 / :行:列)切掉,tab 标题同样按切完的文件名给——否则点开就是一个报「文件不存在」的 x.c:131。切分只在这一步发生(src/client/path-line.ts),文件树 / 搜索 / 插件自有流程的路径不经过它;代价是从聊天链接打开一个真名以 :数字 结尾的文件会丢掉后缀(Windows 上 : 本就不是合法文件名字符)。行号随后顺着记录一路走到 viewer:两种写法落到同一行(#L131 由宿主解析成 navigation.params.line,path:131 由插件切出来,两者合流到 SidebarTab.line),文本编辑器在到达时定位一次并高亮该行,越界行号钳到最后一行。区间只取起始行、列号丢弃——编辑器只有一处光标。line 是瞬态:sanitizePersistedTab 的白名单不含它,刷新后不会把读者拽回几天前的那一行。markdown / html 收到行号时会强制切到编辑态再定位(预览里没有源码行号可放,而它自己的挂载滚动恢复也会和跳转打架)——引用要的是源码。这条强制是一次性导航、不是文件的模式:viewer 定位完成后回调 onLineConsumed?.(),宿主(EditorHost,它比 viewer 活得久——一次 reload 会把 viewer 整个重建)就停止再下发同一个 line;不这样做的话,读者点「预览」触发的 reload 会让重建出来的 viewer 重新强制切回编辑态,永远回不去。不消费 line 的 viewer(如按扩展名认领、只渲染图片的)与本行为无关。两条入口现在一致:聊天正文由宿主解析出 navigation.params.line;插件自绘的 markdown 预览(编辑器预览 / 侧边对话转录 / 变更阅读窗 / 任务窗口)在 MarkdownDelegate.openFile(path, { line }) 里收到同一个数字,转成 seed 的 line 再走同一条链路——所以预览里点 #L42 与聊天里点 #L42 行为相同(跳到第 42 行 + 切到编辑态)
链接接管(v0.21.1+) DOM 层只接管有类型通过 urlTarget 声明认领的外链(Ctrl/Cmd/Shift/Alt 点击一律放行);一个都没认领到时不阻止默认行为,交回宿主。见 §4.1 的 urlTarget
path 种子的去向(v0.19.2+) path seed 的含义跟随类型:只有 editor(唯一认领 dsh-resource://file/** 的类型)把 path 转成资源地址打开(文件落在编辑器);其余类型保留页面型打开,path 随导航 params 落到合成记录的 tab.path 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632)
文件打开的认领判定 插件自己的一切文件入口(文件树点击 / 路径输入 / Git 页 / 「新标签页打开」,合并与分屏两种模式)都先问宿主原生 tab 注册表:ctx.sidebarRightTabs.candidates(address) 的头名若不是本插件的 editor,地址改走 ctx.sidebarRight.openResource,由认领类型渲染自己的 tab——第三方直接在宿主注册表按更具体 glob 注册的原生类型(如 .drawio 画板)从此在文件树里点得开(#695);无人认领、宿主无注册表或探测失败时行为逐字保持原样(落到 editor / code 兜底)
终端(宿主一条 + 插件底部一条,彼此独立) 插件不在原生右侧栏提供终端:宿主 0.1.6 起自带 ui-sidebar-terminal(kind terminal),插件侧 PTY 栈与 terminal_* 工具整体删除。但插件自己的底部工作台有一条终端(terminal-bottom,#774):它不注册原生类型、不占 guide 条目(bottomOnly),页面是插件自绘的 xterm,进程由宿主的会话级终端服务 ctx.webTerminals 供给——插件不持有 PTY,也不与右列那条共享进程(两条终端各自独立是有意的,右列仍是宿主的地盘)。宿主没挂终端控制器时该类型 available 为 false(菜单置灰),页面显示「宿主未提供终端能力」而不是崩
bottomOnly(#774) 只投底部、永不注册原生承载面的类型(字段说明见 §4)。它同时意味着:openTab 落点强制 'bottom'(连 target: 'side' 也被改道——原生栏里的 tab 不在底部工作台的分栏树里,两者归属矛盾)、原生 tab 类型与 guide 条目都不注册。hidden: true 不是它的替代品:那会连 + 菜单一起摘掉(buildNewTabOptions 过滤 !hidden),而它要的正是「留在 + 菜单、只离开原生栏」
底部终端的恢复语义(#774) 刷新后重新上屏同一页签时,页面用同一份 (sessionId, key, contentId) 再次调用宿主的 webTerminals.view():宿主持久化在 localStorage 的 (sessionId, contentId) → 终端 id 绑定因此把同一个 PTY 认回来(contentId 是 dsh-better-sidebar:terminal-bottom:<instance>——每个页签一个实例,不再是恒定值,#926)。插件不把 terminalId 写进 tab.meta:view() 显式传 terminalId 会关掉宿主的「缺了就建」分支,一个过期的 id 会把页签永久卡在 missingTerminal 上。DSH 重启后 PTY 全没了而 localStorage 绑定还在,所以「新建终端」按钮把 tab.meta.terminalRun +1 换一代 contentId,让宿主另铸一个进程,而不是反复认领死绑定
底部工作台的开合 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 openTab 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(conversation.session.header.utilities),不在插件自己的宿主里
在侧边打开(target: 'side',v0.22.0+) 原生栏里的 tab 不在插件底部工作台的分栏树里,所以「在侧边打开」不能写 bottomSplits(那样会落到用户没展开的底部工作台 = 点了没反应)。服务改为把这一步交给宿主:带 path 的 editor seed 走 openResource(address, { preferNewPane: true, revealIfOpened: false })——宿主先按自己的两格上限与空间规则尝试分栏,分不了才回退到当前格;revealIfOpened: false 允许与已打开的同名资源并存,因此对同一个文件再点一次也会新开一格。其余类型若传 target: 'side',同样以 preferNewPane 落原生栏(组件型 tab 的 path 仍是组件种子)——唯一的例外是 bottomOnly 类型(#774):它没有原生承载面可落,target: 'side' 被改道回 'bottom'。path-less 的 editor(文件页)与 'bottom' 行为不变
新建标签页列表 每个 tab 类型在原生 guide 里占一行:标题取 title + 图标取 icon(缺图标时宿主补一个方块占位),说明取可选的 description——宿主只在 guide 列出的条目 ≤ 4 条时渲染说明(上游 MAX_DESCRIBED_ENTRIES = 4),更长的列表整列丢掉所有说明;未声明 description 的条目渲染成单行「图标 + 标题」(rc.1 起 description 回到宿主契约,但宿主与插件都没有兜底句,所以插件恢复字段而不恢复旧的通用句);hidden: true 的类型不占行,bottomOnly: true 的类型同样不占行(#774:它只活在插件自己的底部 + 菜单里)。插件的 editor 类型不再单独占行(它认领的文件资源由 files 接管页承载同一视图)。本插件默认贡献 4 个 guide 条目(文件 / Git / 任务管理 / 侧边对话,恰好在上限内),但宿主的终端条目也占一行——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条
新建面板的种子(alpha.2) 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。revealIfOpened 打开的「页面」在同一 pane 内强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响
alpha.2 全局面板(不接入) 插件不采用 alpha.2 引入的全局主面板模型——根级 keyed main 槽(预留 key conversation,由 ui-conversation 注册为 main.conversation)、根级 sidebar.panellist 列表槽(SidebarPanelMetadata / SidebarPanelIconOwnerProps)、`ctx.layout.selectPanel(MainPanelId
dsh-context 可选联动(开发线) 通过 Cordis 子作用域等待宿主服务,并订阅原生类型及内容槽注册;仅当 Context 侧栏可用时显示 Agent 详情、后台任务真实属主与当前会话标题栏的诊断入口。跨会话先导航到目标再打开 Context,复用该会话其他分栏已有标签;发起导航即关闭来源详情/输出浮层,插件级请求不随浮层卸载取消;导航最多等待 10 秒,用户继续导航或依赖消失即结束。后台任务没有真实 job.owner 或可靠子代理地址时不提供诊断,不回退到观察会话。只借用 ctx.layout.beginNavigation() 的取消语义,不贡献全局主面板;不扩展 ctx.betterSidebar 公共 API,也不要求消费插件改代码或安装 dsh-context。
已移除 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与自由窗口(features 里的 'floatWindows' 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);openByDefault / defaultWidthPercent / changesDiffFloat 三个设置项同步删除(旧文档里的键会被忽略);插件自带的终端与浏览器 tab 类型(宿主 0.1.6/0.1.7 自带两者)与只读文件预览(image / pdf / Office / 表格,宿主 0.1.7 的 ui-sidebar-documentpreview 接手)同样不再内置,详见 §4.4 与 §5.4

1. 总览:你能扩展什么

better-sidebar 从 v0.4.0 起把自己改造成一个注册表服务:

  • 新页面(tab):注册一种新的侧边栏 tab 类型,出现在侧边栏 + 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
  • 文件预览器(file viewer):注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 markdown/html/code)。宿主自己也有文档预览——DSH 0.1.7 的 ui-sidebar-documentpreview 拿走了表格 / PDF / 图片 / Office 等只读格式,本插件因此拒绝认领那些扩展名,你的 viewer 也接不到它们(清单与原因见 §5.4)。

内置的 6 个 tab(editor / git——「Git」统一 tab(Source Control 视角 + 本轮文件视角,Git 视角内含只读提交图)/ subagent / sidechat / terminal-bottom / diff)和 4 个 viewer(markdown / html / jsonl / code)自己也是通过同一套 API 注册的(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。不再内置 terminal / browser 两个 tab 类型:DSH 0.1.6 自带右列终端(ui-sidebar-terminal、kind terminal),浏览器是 0.1.6 的 ui-sidebar-browser(kind browser,0.1.7 起只在 desktop profile 挂载,Web profile 没有这个 kind);插件的同名类型会让读者看到两份实现。同理,0.1.7 的 ui-sidebar-documentpreview 让插件的 image / pdf / Office / 表格预览失去意义,那三个 viewer 描述符(image / pdf / binary-download)已删除。

关键机制一句话:better-sidebar 的 client half 在 apply() 开头执行 ctx.provide('betterSidebar', service)(src/client/index.tsx),消费插件在 inject 里声明 'betterSidebar',Cordis 保证服务就绪后才激活你的插件,然后你调用 ctx.betterSidebar.registerTab(...) / registerFileViewer(...) 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。

⚠️ 服务只在 client half:ctx.betterSidebar 只存在于浏览器侧。你的插件 host 半没有这个服务;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(/sidebar/api/*、/sidebar/file、/sidebar/ws/*),不走服务。


2. 前置:类型合并与依赖声明

2.1 类型合并(统一 @deepseek-ai/cordis)

DSH 运行时和生态的类型正主是 vendored @deepseek-ai/cordis。你的插件解析到它的 Context 时,ctx.betterSidebar 不会自动出现——由 better-sidebar 用 declare module '@deepseek-ai/cordis' 补上:

import type {} from 'dsh-better-sidebar'  // 触发 declare module '@deepseek-ai/cordis' 类型合并

这个 type-only import 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。

2.2 package.json 声明

{
  "name": "my-plugin",
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "dsh-better-sidebar": "workspace:*"
  },
  "peerDependenciesMeta": {
    "dsh-better-sidebar": { "optional": true }
  }
}
  • dsh-better-sidebar 必须是 peerDependency(不是 dependency),避免两份实例;
  • optional: true:better-sidebar 未安装时你的插件照常加载,注册代码因为 ctx.betterSidebar 为 undefined 而安全跳过。

⚠️ 计划上架 DSH 插件市场的插件还须遵守市场 manifest 约束(dependencies/peerDependencies/optionalDependencies 一律不得出现 cordis、scripts 不得含 install 类钩子),详见 AGENTS.md 硬约束一节。

2.3 类型导入路径

// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
import type {
  BetterSidebarService,
  TabDescriptor,
  TabComponentProps,
  FileViewerDescriptor,
  FileViewerProps,
  FileFetchStrategy,
} from 'dsh-better-sidebar'

// 方式二:子路径(与主入口等价)
import type { TabDescriptor } from 'dsh-better-sidebar/client/service'  // 别名 ./client/api

v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):

import type {
  SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
  SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'

💡 类型合并触发路径:import type {} from 'dsh-better-sidebar/client/service' 同样会加载 Context 的 augmentation(declare module '@deepseek-ai/cordis' 在 context-types.d.ts 中)——纯浏览器侧插件建议走 client/service 路径,避免拉进宿主半的 Node 类型图(主入口 dsh-better-sidebar 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(client/* + context-types + html-route + prefs-shared)自 v0.12.0 起零 Node 依赖(scripts/check-consumer-types.sh 守护),没有 @types/node、skipLibCheck: false 也能编译。


3. 最小骨架(client half)

// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar'          // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'

export const inject = ['betterSidebar', 'slots']   // 声明服务依赖(slots 可选,按需)

export function apply(ctx: Context): void {
  // 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
  ctx.effect(() =>
    ctx.betterSidebar.registerTab({
      id: 'my-plugin:db',
      title: () => 'Database',
      icon: <DbIcon />,
      order: 50,
      component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
    })
  )

  // 注册一个文件预览器
  ctx.effect(() =>
    ctx.betterSidebar.registerFileViewer({
      id: 'my-plugin:csv',
      exts: ['csv'],
      fetchStrategy: 'custom',
      load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
      component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
    })
  )
}

要点:

  • 注册必须包在 ctx.effect(...) 里。registerTab / registerFileViewer 返回 () => void disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 "already registered"。
  • inject = ['betterSidebar'] 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
  • 注册在 apply 内任意时刻都行;服务在 better-sidebar 的 apply() 开头就绪。

4. 新页面(Tab)注册 API

4.1 TabDescriptor 完整字段

// 设置行控件形状(settings.toggles / settings.pluginToggles 共用;text/number v0.11.0+,select v0.13.0+,patterns 为可编辑字符串数组):
interface SettingRow {
  key: string                              // toggles: SidebarPrefs 字段名;pluginToggles: 插件局部 key
  title: string | (() => string)
  desc?: string | (() => string)
  type?: 'switch' | 'text' | 'number' | 'select' | 'patterns'  // 缺省 'switch'
  min?: number; max?: number               // number 行提交钳制
  placeholder?: string; unit?: string      // text 行占位符 / 单位后缀(如 'px')
  patternsPlaceholder?: string             // patterns 行添加输入框占位符
  options?: readonly {                     // select 行选项
    value: string | number | boolean
    title: string | (() => string)
    desc?: string | (() => string)
    icon?: ReactNode | ((size: number) => ReactNode)
  }[]
  multi?: boolean                          // select 多选(缺省 false;存 value 数组,按 options 顺序提交)
}
// text/number 行 blur/Enter 提交;select 任一项带 icon 时渲染大图标选项卡,否则单行文本。

interface TabDescriptor {
  /** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
  id: string
  /** 标题(i18n 友好:传字符串或返回字符串的函数) */
  title: string | (() => string)
  /**
   * 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。
   * **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`),
   * 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。
   * 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上
   * 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。
   */
  description?: string | (() => string)
  /** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */
  icon?: ReactNode | ((size: number) => ReactNode)
  /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal-bottom=40 */
  order?: number
  /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
  hidden?: boolean
  /**
   * 只投放到插件自己的底部工作台,**永不注册原生右侧栏承载面**(#774)。
   *
   * 效果有两处,都必须同时成立才有意义:
   * - `openTab` 落点强制为 `'bottom'`(即使调用方传了 `target: 'side'`)——
   *   原生栏里的 tab 不在底部工作台的分栏树里,「在侧边打开」一类请求原本会
   *   在宿主侧另起一个 tab,与这个页面的归属矛盾;
   * - 原生承载面既不为它注册 tab 类型,也不为它占 guide 条目——缺了这一半,
   *   `+` 菜单里那一行会变成原生栏里第二个语义重复的胶囊。
   *
   * **它不是 `hidden` 的替代品**:`hidden` 会连 `+` 菜单一起摘掉(`buildNewTabOptions`
   * 过滤 `!hidden`),而 `bottomOnly` 要的正是「留在 `+` 菜单、只离开原生栏」。
   * 内置用例是 `terminal-bottom`:DSH 自带的右列终端已经占住 `terminal` 这一格,
   * 插件自己的底部终端必须**不被看成第二个终端入口**(挂载 lane 钉住宿主
   * `terminal` guide 条目恰好 1 条)。用它的类型请自备页面组件——原生栏的
   * 「页面级」承载面(guide 胶囊 / 原生芯片 / `keepMounted`)一概不提供。
   */
  bottomOnly?: boolean
  /** + 菜单禁用判定(用量配额一类)。返回 false 只影响菜单 disabled,不拦截 openTab(只有设置页禁用开关会)。 */
  available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
  /**
   * 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
   * 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
   */
  single?: boolean
  /**
   * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
   * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
   * 内置策略:git/subagent 用 single;editor 用 tab.path;diff 用 tab.id。
   * 必须纯函数:每次 open 求值两次,抛错向外传播。
   */
  dedupeKey?: (tab: SidebarTab) => string | undefined
  /**
   * 自定义 tab 创建(minting SidebarTab + 状态 patch)。
   * 返回 null 拒绝创建。sidechat 用它实现「一个线程一个 tab」。
   * 省略时用默认 { id, type, title } + seed 里的 path/diff。
   */
  createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
  /**
   * 外链认领(v0.13.0+,`features.includes('urlTarget')` gate):声明后,DOM 层
   * 捕获到的外链点击里**只有被某个启用类型认领的那些**会被接管——第一个
   * urlTarget 返回 true 的类型以 openTab({ type, url, title: hostname }) 打开,
   * URL 预填 tab.path。先到先得;谓词抛错被吞。
   *
   * **没被认领的链接一律放行**(插件不 preventDefault):DSH 0.1.7 起正文链接的
   * 去向由宿主的用户设置 `linkOpening` 决定(进侧栏还是新标签页),插件自绘
   * markdown 里则走 `<a>` 自己的默认行为。**v0.21.1 起那三个「按协议分流」
   * 的旧设置项已删除**(旧文档里的键会被忽略),本插件不再有任何接管总闸——
   * 认领与否完全由你的 urlTarget 决定;Ctrl/Cmd/Shift/Alt 点击永远绕过接管。
   * 认领成功但目标类型在打开那一刻已不可用(插件卸载 / 被设置关闭)时,点击
   * 兜底为 `window.open(url, '_blank', 'noopener,noreferrer')`,不会变成静默无反应。
   *
   * 多 URL 并存需 createTab 铸造 per-URL id,否则二次点击被 id 安全网聚焦、不覆写 path。
   */
  urlTarget?: (url: URL) => boolean
  /**
   * 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
   * 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
   */
  settings?: SidebarSettingsDeclaration
  /**
   * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
   * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
   * 抛错会被吞掉(不显示角标,不影响渲染)。
   */
  badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
  /**
   * tab 栏右侧操作区(v0.25.0+):tab 激活时,返回的 ReactNode 渲染在该 pane 的
   * tab 栏右端(+ 按钮右侧、面板关闭按钮左侧,右对齐)。让 tab 把自己的工具栏
   * 直接放进 tab 栏,而不是在内容区顶部再单独渲染一行头部。返回 null/undefined
   * 完全不渲染(连空容器都不挂载);抛错被吞掉(不渲染操作)。每次 tab 栏渲染
   * 都会调用——保持廉价。
   *
   * `tab` 是当前渲染的打开实例:`single: false` 且有分栏时同一类型可同时开多个
   * 实例、每个 pane 各渲染自己的 tab 栏,重启/关闭等操作必须针对 `tab` 而不是
   * 「该类型的那个 tab」。`paneId` 是这条 tab 栏所在的 pane(与
   * `state.activePane` 比较可知该 pane 是否为聚焦 pane)。
   */
  rightActions?: (ctx: Context, scope: SessionScope, state: SidebarState, tab: SidebarTab, paneId: string) => ReactNode
  /**
   * 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
   * - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开;原生面每次打开都触发);
   * - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
   * - onClose:closeTab 关闭 tab 后(原生 tab 同样携带完整 tab,含 meta;用户点宿主 × 的关闭除外)。
   * 内置专属流程(diff 拆分放置、sidechat 线程重开)直接改 state,不触发
   * 回调——但它们只作用于内置类型(diff / sidechat),外部插件的 tab 永远走
   * service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
   * openTab 回调 scope 携带调用者传入的 { sessionId, cwd? };
   * closeTab/activateTab 仅显式传 scope 时带 cwd。
   */
  onOpen?: (tab: SidebarTab, scope: SessionScope) => void
  onActivate?: (tab: SidebarTab, scope: SessionScope) => void
  onClose?: (tab: SidebarTab, scope: SessionScope) => void
  /** 渲染函数 */
  component: (props: TabComponentProps) => ReactNode
}

/** 声明式设置声明(行控件形状见上方 SettingRow)。 */
interface SidebarSettingsDeclaration {
  /** 偏好字段行(key 必须落在本插件的 `PrefsSchema` 里——它已并入本插件 Loader 行的 `Config`,内置键清单见 §8)。 */
  toggles?: readonly SettingRow[]
  /** 插件自有设置行(v0.12.0+):key 插件局部,
   *  持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
  pluginToggles?: readonly SettingRow[]
  /** 自定义设置面板(v0.12.0+):追加渲染在行列表之后,可单独存在;
   *  抛错被吞并显示内联错误。 */
  render?: (props: SidebarSettingsRenderProps) => ReactNode
}

/** settings.render 收到的 props(v0.12.0+)。 */
interface SidebarSettingsRenderProps {
  store: SidebarStore
  service: BetterSidebarService
  prefs: SidebarPrefs
  /** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
  pluginSettings: Record<string, unknown>
  /** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
  updatePluginSetting(key: string, value: unknown): void
  /** 关闭设置弹窗。 */
  close(): void
}

4.2 TabComponentProps(你的页面组件收到的 props)

interface TabComponentProps {
  ctx: Context                 // client cordis context
  store: SidebarStore          // better-sidebar 的状态 store(可调 reduce 等)
  scope: SessionScope          // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
  tab: SidebarTab              // 当前 tab 实例(含 id/type/title/path?/diff?/meta?)
  visible: boolean             // 是否当前激活 tab 且面板打开(不可见时暂停轮询等)
  // 以下由内置 tab 使用,外部 tab 可忽略:
  expanded?: string[]          // 文件树的展开目录集
  onToggleDir?: (path: string) => void
  // 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/`
  // (含空格时按宿主语法加引号成 `@"my dir/"`,否则宿主读不成 folder),
  // 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip
  // (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。
  // path 按宿主确认的 workspace root(客户端 `ctx.get('workspaces')` 的
  // `WorkspaceView.path`,按会话成员匹配)投影,不是会话 cwd:@ 语法以
  // workspace root 为基准。工作区数据不可用(服务缺失 / 快照未就绪 /
  // 会话不属于任何工作区)或路径在 workspace 之外时不写入草稿,只留一行
  // warn——绝不产出猜测的相对路径或绝对路径。目录插入会带一个尾随分隔空格,
  // 使连续两次点击不会粘成一条 token。
  onReferenceFile?: (path: string, isDir: boolean) => void
  onOpenFile?: (path: string) => void
  onOpenDiff?: (tab: SidebarTab) => void
  onSubagentJump?: (childSessionId: string) => void
}

实践建议:

  • 用 visible 做性能门:subagent 内置页在 visible === false 时暂停轮询;你的页面若有轮询/订阅,同样处理。
  • 用 scope.sessionId(+ scope.cwd)访问会话数据:所有 /sidebar/api/* 请求都要带这两个字段(见 §6)。

4.3 注册示例

最简单实例 tab(+ 菜单可见):

ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:notes',
    title: 'Notes',
    icon: <NoteIcon />,
    order: 50,
    single: true,  // ≡ dedupeKey: () => 'my-plugin:notes'
    component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
  })
)

多实例 tab + 外部触发打开(每次新开,带自定义 id):

ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:doc',
    title: 'Doc',
    icon: <DocIcon />,
    order: 60,
    // 不设 dedupeKey:每次 openTab 都新开
    component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
  })
)
// 外部触发打开(你的插件其他流程、甚至用户操作):
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })

条件可见(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):

ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:commits',
    title: 'Commits',
    icon: <CommitIcon />,
    order: 70,
    available: (ctx, scope, state) => hasGitRepo(state),
    dedupeKey: () => 'my-plugin:commits',
    component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
  })
)

自定义创建(mint 自增 id):

ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:console',
    title: 'Console',
    order: 80,
    createTab: (state) => ({
      tab: { id: `console:${state.nextBrowser}`, type: 'my-plugin:console', title: `Console ${state.nextBrowser}` },
      patch: { nextBrowser: state.nextBrowser + 1 },  // 借内置计数器;也可自建 state 字段
    }),
    component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
  })
)

认领外链点击(v0.13.0+,urlTarget + createTab 组合):

ctx.effect(() => {
  if (!ctx.betterSidebar.features.includes('urlTarget')) return  // 老版本优雅降级
  return ctx.betterSidebar.registerTab({
    id: 'my-plugin:web-docs',
    title: () => 'Docs',
    order: 80,
    urlTarget: (url) => url.hostname === 'docs.my-site.com',
    createTab: (state) => ({  // per-URL id,多 URL 并存
      tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
      patch: { nextBrowser: state.nextBrowser + 1 },
    }),
    component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
  })
})

4.4 内置 tab 清单(不可重复注册)

id order single hidden 用途
editor 10 否(按 path 去重) 否 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 fs.search;状态存 tab.meta.treeOpen/treeWidth)。editorExplorer:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 updateTab 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(title:'Files');旧 explorer tab 经 sanitizeState 迁移。树 listing 与文件名搜索应用 explorerExclude 排除模式(editor 卡片齿轮弹窗可编辑;host 侧过滤,匹配条目彻底移除且永不阻塞折叠);单目录链折叠为 a/b/c 面包屑行(POSIX 隐藏与排除条目不计入唯一子项判定)
git 20 是 是(本轮文件操作数) 「Git」统一 tab(id 保留 git 以兼容持久化布局):Git 视角(Source Control:独立折叠的 Changes / Graph;Changes 内为多行 Message + 仅填草稿的 Generate、Merge Changes → Staged Changes → Changes(资源组也独立折叠,计数常驻标题最右,文件行整行 hover);Git 刷新仅留在 Changes 标题,外层本轮文件刷新保留;真实 parent hashes 泳道图;常驻底栏为可点击分支 + */+/! 标记、upstream 的 behind↓ / ahead↑ 及安全 Pull-then-Push 同步;Push/Pull 与 worktree·子仓库选择移入 More Actions;提交只消费已暂存项,冲突/空消息/无暂存时禁止,草稿按会话·仓库·worktree 隔离)+ 本轮文件视角(原 file-trace:模型读/写/编辑实时折叠,按操作时间倒序平铺、不按文件分组,每行「操作 → 文件路径 → 大小/时间」,支持类型筛选);会话事件经插件自有宿主路由 changes.ops 供给(live 日志优先、冷会话回放持久化记录,afterSeq 增量),primed 后用 wait 挂在宿主侧等下一个工具事件而不是重放整窗),badge 读 tab 轮询写入的同步缓存。页头复用宿主会话页的下划线 Tabs 样式(wSkVaW_tabs/tab/tabActive,宿主尚未公开组件;支持左右方向键与 Home/End 切换)。两视角共用底部可拖拽预览面板(tab.meta.lens/previewH 持久化),diff 渲染统一走 src/client/diff/(DiffRows/DiffFiles:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏)
subagent 30 是 否 任务管理(工作流图/树、团队任务板、后台任务抽屉)
sidechat 35 否(sidechat:<uuid>,按 meta.threadId 去重) 否 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 interrupted 闭合;种子带合法 subagent/descriptor,SubagentView 按 Side: 前缀过滤),origin:'subagent' 隐藏于主列表;走 /sidebar/api/sidechat.* 路由;头部菜单切换/重开(parkSidechatReopen + 确定性 id),关 Tab 释放 live agent;重开经 collectOwnEvents 回源到种子边界;「保存为新会话」= session.fork(this 敏感)。设计文档
diff -1 否(按 id 去重) 是 差异查看(changes tab 的预览面板「展开为独立页签」触发,同一渲染栈)
terminal-bottom 40 否(createTab 每次铸新 id) 否 底部工作台的终端(#774)。bottomOnly(见 §4 与「原生承载面」表的说明):没有原生 tab 类型、不占 guide 条目,只在底部 + 菜单里。页面是插件自绘的 xterm(lib/client-terminal.js chunk),进程由宿主的会话级终端服务 ctx.webTerminals 供给(结构化探测,不写进 dsh.client.inject——缺服务只会让这一行置灰,不会把整行拖成 pending)。恢复靠宿主持久化的 (sessionId, contentId) → 终端 id 绑定(contentId 形如 dsh-better-sidebar:terminal-bottom:<instance>,每个页签一个实例,#926),关页签走 onClose → webTerminals.close() 结束进程(卸载不关:宿主的契约是「视图跨 DOM 卸载存活」,刷新要能认回同一个 shell),tab.meta.terminalRun(默认 0)是「新建终端」换代号用的代号位。设计文档

你的 id 不可与上述重复,否则 registerTab 抛 "tab type \"X\" already registered"。

自 v0.20.0 开发线起删除的类型(迁移提示;0.20.0 从未发布,变更落在 v0.21.1):

原 id 现状 你该怎么做
terminal 宿主自带 kind: 'terminal'(@deepseek-ai/dsh-client-ui-sidebar-terminal) 不要再注册同名 kind。插件侧 API(api.ptyClose / agentPtyClose / agentSkipWait / terminalDeps / shellGet)与 SidebarTab.pin / SidebarState.nextTerminal / agentWaits / isAgentTabId / agentUuidOf / reconcileAgentTerminals / mirrorAgentWaits / setTabPin 全部已删除
browser 宿主自带 kind: 'browser'(@deepseek-ai/dsh-client-ui-sidebar-browser),但 DSH 0.1.7 起只在 desktop profile 挂载(web-app 的 patch 里 disabled: ctx.get('profileContext')?.name !== 'desktop')——Web profile 上没有这个 kind,openTab('browser', …) 必然失败 不要再注册同名 kind。要打开网页:desktop profile 走 ctx.sidebarRight.openTab('browser', { params: { url } })(务必 try/catch 兜底,宿主未装该包时 openTab 会抛);Web profile 只能自己提供页面,或把链接交回宿主(正文链接由宿主的 linkOpening 设置决定去向)。插件侧的 api.browserProbe / SidebarPrefs.browserNoSandbox / browserAllowedLoopback 以及三个「按协议分流」的外链接管偏好键全部已删除;urlTarget 机制保留,但不再有接管总闸(见 §4.1)

同时删除的还有:轮尾产物行接管。DSH 0.1.6 把 conversation.chat.turnTail 从 chain 改成 list(list 槽要求 options.id,且只能追加,不能替换宿主的产物行),插件因此整体移除了 registerTurnTailInterception 与 selectProducedFiles。要往那个位置加东西,按 list 契约注册 { name: 'conversation.chat.turnTail', id: '<你的 id>', order };openSidebarFile 作为通用打开工具留在 dsh-better-sidebar/src/client/sidebar-file.ts(resolveSidebarPath 移到 src/client/paths.ts)。


5. 文件预览器(FileViewer)注册 API

5.1 FileViewerDescriptor 完整字段

interface FileViewerDescriptor {
  /** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
  id: string
  /** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
  title?: string | (() => string)
  /** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
  icon?: ReactNode | ((size: number) => ReactNode)
  /** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级盲命中有效) */
  exts: readonly string[]
  /** 优先级(高优先);默认 0。内置:markdown/html=0,code=-100 */
  priority?: number
  /** 字节获取策略 */
  fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
  /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
  detect?: (path: string, head: Uint8Array) => boolean
  /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
   *  卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
  load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
  /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(§4.1 的
   *  toggles/pluginToggles/render;v0.12.0 起 viewer 卡片也有齿轮按钮) */
  settings?: SidebarSettingsDeclaration
  /** 渲染函数 */
  component: (props: FileViewerProps) => ReactNode
}

5.2 FileViewerProps

interface FileViewerProps {
  ctx: Context
  store: SidebarStore
  scope: SessionScope
  path: string
  title: string
  viewerId: string         // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
  content?: string         // fetchStrategy='fsRead' 时;超过编辑上限的文件不带它
  size?: number            // 本次读取量到的文件字节数
  largeFile?: { size: number }  // 见下方「超大文件」
  mtimeMs?: number         // fetchStrategy='fsRead' 时:本次读取的磁盘 mtime(保存冲突基线)
  mediaUrl?: string        // fetchStrategy='mediaUrl' 时
  customData?: unknown     // fetchStrategy='custom' 时(load() 的返回值)
  // 引用带行号时才有(#826,见 §0「地址里的行号」):一次性导航,不是文件的属性
  line?: number            // 1 基行号(如 `a.c#L131` / `a.c:131`);定位用得上的 viewer 消费它
  onLineConsumed?: () => void // 已消费该 line 的回报;不调用则每次加载都会重新下发(默认不消费)
  // 以下为内置文本编辑器与 EditorHost 内部协作字段,外部 viewer 忽略:
  toolbar?: 'self' | 'host'
  visible?: boolean        // 本 tab 是否在屏(原生 tab 停驻时仍挂载)
  onToolbarState?: (state: EditorToolbarState) => void
  onToolbarControls?: (controls: EditorToolbarControls | null) => void
  onReload?: () => void    // 从磁盘重新加载(保存冲突横幅的按钮回调)
}

超大文件(v0.26.0 起,truncated 已被取代):文件超过编辑上限(5 MiB,见 src/editor-limits.ts 的 MAX_EDITABLE_BYTES)时,fs.read 不再回一份被截断的文档,而是 { kind: 'too-large', size, mtimeMs, head } —— 不带 content。宿主把这个结果交给命中的 viewer 时,content 缺席、largeFile: { size } 到位(见 planFsReadOutcome)。内置的文本 viewer 据此渲染只读分页预览(LargeFileView),并提供下载出口;你的 viewer 必须容忍 content === undefined:要么自己处理 largeFile(分页预览 / 提示),要么把状态回给 onToolbarState 让宿主收起保存按钮。旧的 truncated 布尔字段与 EditorToolbarState.truncated 都已删除——「拿到半份文档但看起来能编辑」这个状态本身没有了,这正是它被删掉的原因(#732:保存半份文档会毁掉文件尾部)。

分页读取:fs.readPage({ sessionId, cwd?, path, offset }) 按字节窗口返回一页文本(宿主按行对齐,并在同一个文件内锁定宽字符编码),返回 { kind:'text', text, offset, nextOffset, eof, size, mtimeMs, encoding }。消费方式就是只向前追加:把每页的 text 接到一起,下一次请求用上一页的 nextOffset 作为 offset,直到 eof。因此必须串行(下一页的偏移就是上一页的响应),也不能跳到末尾——随机访问需要打开时先扫全文件建索引,而那正是分页要避免的开销。内置预览在这之上加了两个策略:滚到底自动追加下一页,以及一条按渲染行数设的上限(LargeFileView 的 MAX_PREVIEW_ROWS),到顶后停止追加并提示下载。

模型改完文件后的自动刷新(#855):EditorHost 订阅插件自有的会话事件增量流(changes.ops,每会话一个共享轮询器,2.5s 增量、无事件不落盘读),当当前会话的某次 write / edit 工具调用结算成功且路径命中本页文件时,自动重跑一次加载——即把页头的刷新按钮变成自动的(走同一条 fetchStrategy / load() 路径,mtimeMs 基线语义不变;命中判定用 resolveSidebarPath 把模型写的相对路径按会话 cwd 归一)。对第三方 viewer 有两条含义:① 你什么都不用做就会拿到新内容;② 若你的 viewer 持有未保存的草稿,必须在 onToolbarState 里如实上报 dirty: true——dirty 时自动刷新会跳过(脏态优先,绝不覆盖用户输入);未上报 dirty 的 viewer 会被重挂(页面内 state 丢失、content 换成新字节)。处于编辑态的 viewer 同理应上报 mode: 'edit'(那条也会跳过,免得光标跳走)。跳过不等于死路:未 dirty 时既有的「编辑 → 预览」边沿照旧重载并拿到新内容(实测 fsRead 1→2);dirty 时这条边沿自身也被守卫抑制(它的实现同样要求 dirty !== true,EditorHost 的边沿判定),此时用户的两条显式出口是页头刷新按钮(window.confirm 后重载)与**「保存 → fs-conflict 横幅里的 reload」**(保存带 mtimeMs 基线,模型写过就被拒成 fs-conflict 而不是静默覆盖模型字节)。一句话:dirty 期间保持旧内容直到用户显式动作,不存在看不到新内容的死路。

5.3 fetchStrategy 对照

策略 字节来源 传给 component 的字段 适用
none 不需要字节 (无) 自渲染(如纯 UI)
fsRead /sidebar/api 的 fs.read(超大文件转 fs.readPage 分页) content, size, largeFile 文本类(CSV/JSON/XML)
mediaUrl /sidebar/file 媒体路由 URL mediaUrl 图片/PDF(viewer 自己 fetch 字节)
custom viewer 的 load() 函数 customData 自定义协议(如远程拉取)
binary-download 不预览,显示下载按钮 (无) 无客户端渲染器的二进制格式。本插件现在没有任何内置 viewer 用这条策略(binary-download viewer 已让给宿主);策略本身仍在契约里,你的 viewer 可以照用

5.4 匹配算法(matchFileViewer)

matchFileViewer(path, head?) 单趟按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:

  1. 若 head 字节可用且该 descriptor 有 detect → 调 detect(path, head),true 则命中;miss 且是 catch-all(exts: [])则本轮放弃(纯嗅探型不得盲认领);
  2. 否则匹配 exts(小写无点;exts: [] 且无 detect 是盲 catch-all,直接命中)。

即:priority 高的 descriptor 先获得裁决权(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。exts: [] + detect 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 undefined(编辑器显示下载按钮)。

head 字节从哪来:第一次匹配(纯扩展名)没有 head。fsRead 策略读取后若文件为二进制,host 的 fs.read 响应会带 head 字段(base64,前 4KB,src/index.ts 的 READ_HEAD_LIMIT),编辑器会用它对 detect viewer 重匹配一次——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 exts 或 custom 策略替代)。

内置 viewer(不可重复注册,全部 3 个):markdown(0, fsRead;内嵌 HTML 支持:DOMPurify 白名单消毒、<details> 跨段嵌套、本地媒体 src 重写走 /sidebar/file;≥3 标题时浮动目录大纲。实现 markdown-html.ts / MarkdownHtml.tsx / md-toc.tsx,设计文档) / html(0, fsRead, 沙箱 iframe 预览 + htmlViewerNoSandbox / htmlViewerDefaultUnsafe 两个宿主没有的逃生门) / code(-100, catch-all, fsRead, 可编辑 CodeMirror + 保存 + 未提交变更提示(行号着色 / 左侧细色条 / hover blame,一个 editorGitGutter 开关,设计文档))。 code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配找不到 sniffer,落到编辑器的下载面板。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖内置的那三个。

⚠️ 让给宿主的格式(重要,影响你的 viewer 能不能被调用):DSH 0.1.7 的 ui-sidebar-documentpreview 自带 code / excel(xlsx,xls,csv,tsv) / office(宿主侧转 PDF)/ pdf / image / html / markdown / text 预览,并带缩放与按目录自动刷新——本插件原有的 image / pdf / binary-download 三个 viewer 描述符因此删除。更重要的是路由:插件的 editor 类型在 canOpen 里对下列扩展名一律返回 false(src/client/native/index.ts 的 HOST_OWNED_EXTS),把地址让给内置 text 档,根本不会进入本插件的 matchFileViewer:

xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx

这份清单现在是「宿主渲染器实际覆盖的集合」,不多不少:v0.21.1 收回了 9 个宿主其实没有渲染器的扩展名(xlsb / xlt / xltx / xltm / ots / dot / dotx / avif / ods——前两个落在宿主的 document/unviewable.ts「已知二进制、无渲染器」表里,其余落回 text 兜底后按二进制判定失败,一律只有「暂不支持预览」),它们重新由插件的 code catch-all 认领并落到下载面板。fods 保留让出:宿主会用纯文本显示那段扁平 XML,比下载面板有用。tests/native-surface.spec.ts 双向钉住这条边界(宿主能渲染的必须拒绝、宿主不能渲染的必须认领)。

后果:你注册这些扩展名的 viewer 仍然合法(注册表不冻结 id,matchFileViewer 也会命中),但通过聊天 / 文件树 / 编辑器的正常文件打开路径拿不到它们——那是宿主的文档预览。要在这些格式上做文章,只能自己在页面里渲染(或像 Office 预览插件 那样在宿主接管前的旧版本上生效)。可用的是 md/markdown / html/htm 与 code catch-all 三类(外加任何不在上表里的扩展名)。未知二进制(.zip / .wasm)仍走 code 认领 → fs.read 判 binary → head 重匹配无 sniffer → 编辑器渲染下载面板,功能不回归。

5.5 注册示例

CSV 预览器(自定义加载 + 渲染):

ctx.effect(() =>
  ctx.betterSidebar.registerFileViewer({
    id: 'my-plugin:csv',
    exts: ['csv'],
    fetchStrategy: 'custom',
    load: async (path, scope) => {
      const text = await fetchText(scope, path)
      return parseCsv(text)
    },
    component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
  })
)

覆盖内置 markdown viewer(如自家渲染管线的只读预览):

ctx.effect(() =>
  ctx.betterSidebar.registerFileViewer({
    id: 'my-plugin:md-pro',
    exts: ['md', 'markdown'],
    priority: 10,  // 高于内置 markdown 的 0
    fetchStrategy: 'fsRead',
    component: ({ content }) => <MyMarkdown source={content ?? ''} />,
  })
)

⚠️ 只有内置仍认领的格式能被这样覆盖(md/markdown、html/htm,以及 code catch-all 接的其它扩展名)。.svg / .png / .pdf / .docx 之类不要照这个例子注册——地址在 canOpen 就被让给宿主的文档预览了,你的 viewer 永远不会被调用(原因与完整清单见 §5.4)。

内容嗅探(按 magic bytes 路由,忽略扩展名):

ctx.effect(() =>
  ctx.betterSidebar.registerFileViewer({
    id: 'my-plugin:magic-parquet',
    exts: [],  // catch-all,但 priority 高 + detect 精确命中
    priority: 100,
    fetchStrategy: 'custom',
    detect: (_path, head) => head.length >= 4
      && head[0] === 0x50 && head[1] === 0x41
      && head[2] === 0x52 && head[3] === 0x31,  // 'PAR1'
    load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
    component: ({ customData }) => <ParquetTable data={customData} />,
  })
)

6. 页面内如何访问数据(/sidebar API)

你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 fetch better-sidebar 的 JSON API(内置 src/client/api.ts 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):

// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
// 路由经 ctx.betterSidebar.hostRouteUrl() 解析(v0.25.0+):不要写前导斜杠
const res = await fetch(ctx.betterSidebar.hostRouteUrl('sidebar/api/fs.read'), {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json()   // 错误时 { ok: false, error: { code, message } }

绝不写前导斜杠(v0.25.0+,issue #753):fetch('/sidebar/api/…') 与 new URL('/sidebar/ws/…', base) 会把请求钉在源站根。反代把 GUI 挂在子路径时(如 https://host/dataops/proxy/3080/),请求会甩掉前缀 → JSON API 404、懒加载 chunk 加载不出来、媒体/HTML 预览空白、WebSocket 1006;桌面壳下 dsh-app://app/ 更是直接不可达。hostRouteUrl() 用宿主同款的传输基底(壳注入的 __DSH_TRANSPORT__.streamBaseUrl 优先,回落 document.baseURI)解析相对路径,并把前缀原样保留;features 含 'hostRouteUrl' 时可用,旧版本回退到自查 document.baseURI(但拿不到桌面壳的真实基底)。你自己的 HTTP/WS 路由同理:一律相对路径 + 页面前缀。

常用方法(完整清单见 src/client/api.ts):

方法 说明
session.cwd 会话权威 cwd({ cwd, root, parent })
fs.tree / fs.trees 目录列表({ path, entries: FsEntry[], truncated };FsEntry 含 isSymlink/broken,目录软链接的 isDir 按目标类型;请求可带 exclude 模式串,行上可带 compact 标记——单目录链由 client 折叠为面包屑行,匹配条目从列表与折叠探测中彻底移除)。fs.trees 是批量版(文件树的真实入口,同样吃 exclude)
fs.trees 批量目录列表(v0.23.0+):入参 { sessionId, cwd?, paths: string[] }(最多 64 条,绝对或会话相对),一次请求返回 { levels: [{ path, entries, truncated, error? }] }。每层与 fs.tree 同解析、同缓存;某一层失败只在该层带 error(entries: []),整个批次仍是成功响应——树的一次挂载/刷新因此是 1 个请求而不是 N 个
fs.read 读文件(v0.26.0 起三态):文本 { kind: 'text', content, size, mtimeMs };二进制 { kind: 'binary', size, mtimeMs, head };超过编辑上限(5 MiB){ kind: 'too-large', size, mtimeMs, head }(head = base64 前 4KB,用于 detect 重匹配;mtimeMs = 本次读取的磁盘 mtime,保存冲突基线)。不再有 truncated 文本读——截断文档既不可信也不能保存(#732),超大文件改走 fs.readPage 分页预览
fs.readPage 大文本文件的分页读取(v0.26.0+):{ sessionId, cwd?, path, offset } → { kind:'text', text, offset, nextOffset, eof, size, mtimeMs, encoding }。窗口按行对齐(调用方可以直接当作整行/整条记录处理),单个超长行原样返回而不是空页;nextOffset 必然前进,eof 精确
fs.write(已移至 raw 路由) v0.26.0 起保存走 POST /sidebar/write?sessionId=&cwd=&path=&expectedMtimeMs=,请求体就是 LF 文档(不再走 JSON,因此不再受 /sidebar/api 的 1 MiB body 上限约束);响应仍是 { ok: true, value: { ok: true, mtimeMs? } } 信封。语义不变:expectedMtimeMs 是乐观并发门,磁盘 mtime 与基线不符则拒绝(409,wire 错误码 fs-conflict),目标已不存在(保存瞬间被删除或改名走)同样判 409——数字基线只可能来自一次成功的读取,即该路径当时确实有文件,所以「读不到」是冲突而不是新建意图(新建走 fs.createFile、上传走上传路径,都不经过这条分支)。省略则不做门禁;宿主在写盘前按磁盘现状还原文件的行尾与字节编码(restoreEol + encodeText)
git.status / git.diff / git.log 等 全套 Git 只读 + 写操作
pty.close / agent-pty.close 已删除(插件自带的 PTY 栈随终端一起移除;宿主 ui-sidebar-terminal 不通过本插件的路由暴露控制面)
settings.get / settings.update 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 settings-conflict);后端在 0.1.7 上就是宿主的 SettingsForms,见 §8.2

文件路径语义(v0.23.0 起:无包含检查):fs.tree、fs.trees、fs.read、fs.write、fs.rename、fs.remove、fs.mkdir、/sidebar/file、/sidebar/html、/sidebar/upload 和 archive.build 仍以请求对应 session 的权威 cwd 作为相对路径的基准,但不再把 cwd 当边界:绝对路径原样使用,.. 只做词法折叠,符号链接跟随,越界不再是 403。安全影响见 §8.1 的醒目声明。

6.1 打包下载(archive.build / archive.status / /sidebar/archive,v0.22.0+)

把若干文件/目录打成 ZIP 下载,并且有进度可看:选择项在 archive.build 里一次性收集(与 fs.tree 同一套词法解析——无包含检查,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。

// 路由助手同 §6:相对路径交给 hostRouteUrl,别写前导斜杠
const route = (path: string): string => ctx.betterSidebar.hostRouteUrl(path)

// 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形)
const build = await fetch(route('sidebar/api/archive.build'), {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ sessionId, cwd, paths: ['/w/src', '/w/notes.md'], name: '报告' }),
}).then(r => r.json())
// → { ok: true, value: { id: 'ar-…', name: '报告.zip', entries: 2 } }
//    name 是**最终**下载名:调用方只给词干,扩展名由宿主实际可用的工具决定
//    (只有 tar 的机器产出 .tar.gz)

// 2) 轮询:state 为 building | ready | error;bytes 是临时文件已写入的字节数
//    (v0.26.0 起没有 done/total:打包交给系统的 zip/tar 子进程,它不报逐条目进度)
const status = await fetch(route('sidebar/api/archive.status'), {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ id: build.value.id, sessionId }),
}).then(r => r.json())

// 3) 下载(仅 ready 时):同一 id 只能取一次,取完即释放
const url = route(`sidebar/archive?${new URLSearchParams({ sessionId, id: build.value.id })}`)

约束与状态码:任务表最多 4 个并发构建(超出时 archive.build 回 bad-request,HTTP 409),完成/失败后保留 5 分钟(过期即消失);id 只对创建它的 session 可用(其他 session 读是 forbidden 403);/sidebar/archive 在构建中回 409、构建失败回 410(消息即打包工具的 stderr 尾部)、未知/过期/已下载回 404。响应头为 content-type: application/zip(只有 tar 时为 application/gzip)+ content-disposition: attachment; filename="<ASCII 回退>"; filename*=UTF-8''<百分号编码>(非 latin1 文件名走 RFC 5987,ASCII 档位对旧客户端生效)。

v0.26.0 起打包交给系统命令,不再由本插件自己写 ZIP(src/zip.ts 已删除,随之消失的是 ZIP_MAX_ENTRIES / ZIP_MAX_BYTES 两道上限——因此打包不再有大小限制):

  • 工具按 zip → tar 顺序探测(src/archive-tools.ts,结果按进程缓存)。zip 恒带 -y:不加它会跟随符号链接,一个 a -> . 的自指环会一直递归;-y 把链接本身存进包,既不会成环也更忠实。Windows 10 1803+ 自带 tar.exe 而没有 zip。
  • 入参是绝对路径,宿主取它们的最深公共祖先作为工作目录、把相对名交给工具——同一目录下的选择按 basename 入包,不同目录的同名文件自然落在各自父目录下(旧版「逐段长出父目录直到不重名」的算法因此不再需要)。选择本身等于公共祖先时祖先上移一级;选中文件系统根会被 bad-request 拒绝。
  • 命令的 stdout 直接写进 OS 临时目录里的临时文件,从不整包进内存;下载时 createReadStream 流式下发,流结束后才删除临时文件。插件的 teardown 会杀掉仍在跑的构建并清掉临时文件。
  • 两种工具都没有时:archive.build 立刻回 wire 错误码 unavailable(HTTP 501),客户端把它显示成一句可操作的中文/英文提示,而不是把英文 wire 文本原样抛出。

媒体/下载字节走 /sidebar/file 路由(?sessionId=&path=&cwd=&download=1):

// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
const url = ctx.betterSidebar.hostRouteUrl(
  `sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
)

注:内置的 api.ts 是 better-sidebar 内部模块,外部插件 不要 value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可,路由一律经 hostRouteUrl()(或等价的「相对路径 + 页面前缀」解析)。所有 HTTP/WS 路由复用宿主 ctx.connection.requestRejection(req):Host/Origin 与浏览器认证均由宿主裁决,只有返回 undefined 才准入;同源页面也必须先完成宿主登录。


7. 服务方法完整清单

interface BetterSidebarService {
  /** 注册 tab 类型;返回 disposer */
  registerTab(descriptor: TabDescriptor): () => void
  /** 注册文件预览器;返回 disposer */
  registerFileViewer(descriptor: FileViewerDescriptor): () => void
  /** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */
  registerFileIcon(descriptor: FileIconDescriptor): () => void
  /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
  getTabs(): readonly TabDescriptor[]
  /** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
  getFileViewers(): readonly FileViewerDescriptor[]
  /** 当前已注册的文件图标描述符快照(v0.19.0+) */
  getFileIcons(): readonly FileIconDescriptor[]
  /** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再
   *  具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用
   *  fileIcon/folderIcon 全链解析器。 */
  matchFileIcon(path: string): FileIconDescriptor | undefined
  /** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省),
   *  再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序;
   *  返回 undefined = 回退内置 VscFolder/VscFolderOpened */
  matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined
  /** 文件图标权威解析器(v0.19.0+),完整回退链:
   *  ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包)
   *  → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。
   *  任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */
  fileIcon(path: string, size: number): ReactNode
  /** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 →
   *  内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化),
   *  open 会传给工厂,一条注册即可渲染开/合两态。 */
  folderIcon(path: string, open: boolean, size: number): ReactNode
  /** 按 id 查 tab 描述符 */
  getTab(id: string): TabDescriptor | undefined
  /** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
  isTabEnabled(id: string): boolean
  /** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
  isViewerEnabled(id: string): boolean
  /** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
  matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
  /**
   * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
   * title 可选:给出时优先于 descriptor.title(editor 显示文件名);
   * 有 createTab 的 descriptor 分落点:底部工作台(target: 'bottom')由 createTab
   * 整体铸造 tab,忽略 seed 的 title/path/id(url 种子仍预填新建 tab 的 path);
   * 原生右侧栏只忽略 id(原生 tab id 由宿主铸造,seed.id 仅影响 onOpen 收到的
   * 合成 tab),createTab 铸造的 title/meta 作缺省、seed 字段优先(v0.19.2+ 起
   * path 也随导航 params 下发,见下)。
   * path 可选:含义跟随类型——editor(唯一认领 dsh-resource://file/** 的
   * 类型,但会拒绝宿主的文档预览格式,见 §5.4)把 path 转成资源地址打开(文件落在
   * 编辑器);其余类型 path 是组件种子,随导航 params 落到 tab.path(v0.19.2+;
   * 0.19.0/0.19.1 把一切 path seed 都改道文件资源打开,组件型 tab 的组件不会挂载,#632)。
   * url 可选:把**新建** tab 的 path 预填为 URL(网页类 tab 的导航种子);
   * 聚焦既有 tab 时 url 不会覆写其 path。
   * 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。
   * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
   * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
   * 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
   * 与之前完全一致。
   * 内容型打开(带 path/url seed)在落点面板折叠时自动展开,保证落点可见;
   * 类型型打开(+ 菜单等)不展开。
   */
  openTab(seed: OpenTabSeed, scope?: SessionScope): void
  /** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
   *  随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
  closeTab(tabId: string, scope?: SessionScope): void
  /** 订阅注册表变化(register/dispose 时触发) */
  subscribe(listener: () => void): () => void
  // ── v0.12.0+ ──────────────────────────────────────────────────────────
  /** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */
  readonly version: string
  /** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
   *  'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
   *  'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
   *  'settingSelect' | 'fileIcons' | 'rightActions' | 'settingPatterns' |
   *  'hostRouteUrl'
   *  ——用 `features.includes('xxx')` 按能力 gate。 */
  readonly features: readonly string[]
  /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
   *  session 未激活时 state/sessionId 为 undefined。 */
  getSnapshot(): SidebarSnapshot
  /** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer。
   *  listener 抛出的异常被逐个隔离并 `console.error`,既不中断其余监听器
   *  (含 React 自己的渲染订阅),也不会把整条侧栏换成错误条——所以
   *  listener 里读宿主 state 的字段仍请自己判空,别指望异常能当哨兵。 */
  subscribeState(listener: () => void): () => void
  /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op。
   *  **改 path 等于一次新的打开**:引用带的行号(#826)是那次导航的瞬态状态,
   *  会随之清掉——原地切换文件 / 重命名若留着它,下一个文件会跳到上一个文件
   *  的行号(markdown 还会被拉进编辑态)。
   *  **写入在原生承载面上是「稳」的**(v0.26.0+):`path` / `meta` 与宿主自己
   *  投递的导航共用一道闸门——只有该记录**尚未采纳过的导航修订**才允许覆盖
   *  它们,而原生承载面每次渲染都会重投同一次导航;所以插件侧的改址不会被
   *  下一次渲染当渲染垃圾抹掉,宿主**真正**发起的新导航(新修订)仍按地址
   *  改写记录。文件树的重命名 / 删除调解会一并处理原生承载面上的页签(跟着
   *  改址 / 按既有的未保存确认关闭;枚举由承载面内部交回服务,不是公开方法),
   *  消费插件不需要改任何代码。 */
  updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }, sessionId?: string): void
  /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
   *  未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
  activateTab(tabId: string, scope?: SessionScope): void
  /** 消除某个 tab 类型在一个会话里的「有新页面」红点(读者刚看过那一页,
   *  v0.25.0+)。红点由插件自己的后台激活路径点亮(配合 `OpenTabSeed.reveal:
   *  false`),这里是「清除」那一半:承载面渲染了带标记的 tab,就该把它关掉。
   *  未标记的类型是严格 no-op(不通知、不落盘),所以可以在每次可见性变化时
   *  直接调用。底部工作台经 `activateTab` 清除(在那里点击本身就是激活),
   *  原生栏芯片是调用方;`sessionId` 是具名的(原生面同时挂着多个会话)。 */
  clearUnread(type: TabType, sessionId: string): void
  /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
   *  id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件可并排打开)。
   *  注意:path 派生 id 只对 openFile/openSidebarFile 成立;editorExplorer 合并模式的
   *  原地切换经 updateTab 重写 path/title,tab id 保持稳定、不再对应 path。 */
  openFile(scope: SessionScope, path: string, title?: string): void
  /** 把插件自有 host 路由(如 `'sidebar/api/fs.read'`)解析成绝对 URL,
   *  保留反代子路径前缀(v0.25.0+,features 含 'hostRouteUrl')。前导斜杠会被
   *  去掉——带着它就会甩掉前缀、钉到源站根;桌面壳的真实基底也由它给出。
   *  详见 §6 的前缀安全说明。 */
  hostRouteUrl(path: string): string
}

/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
interface OpenTabSeed {
  type: string
  title?: string
  /** 文件路径:editor = 打开文件资源;其余类型 = 组件种子(落在 tab.path,v0.19.2+) */
  path?: string
  diff?: SidebarTab['diff']
  /** 文件引用带的行号(`a.c#L131` / `a.c:131`,1 基,#826)。只对 `editor`
   *  有意义:它是一次性导航——编辑器到达时定位一次(markdown/html 因此
   *  离开预览),随后记录就忘了它。不传 = 普通打开。 */
  line?: SidebarTab['line']
  id?: string
  url?: string
  /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
   *  undefined = 不改,null = 显式清除 */
  meta?: unknown
  /** 落点。省略 / 'right' = 原生右侧栏当前停靠格(默认);'bottom' = 插件的
   *  底部工作台;**'side' = 原生右侧栏的第二个格**——原生承载面提供
   *  `openResource(address, { preferNewPane: true, revealIfOpened: false })`:
   *  先按宿主的两格上限与空间规则尝试分栏,分不了才回退到当前格;允许与
   *  已打开的同名资源并存,所以「在侧边打开同一个文件」真的会新开一格。
   *  path-less 的 editor seed 是文件页(files),'side' 只影响带 path 的打开。*/
  target?: 'right' | 'bottom' | 'side'
  /** 这次打开是否允许**聚焦**(只对 `target: 'right'` 有意义:底部工作台的
   *  打开本来就落在它自己的格子里)。缺省 true——面向消费者的打开都是「给我
   *  看这个」。后台激活(新子代理 / 新后台任务)传 false:宿主只把页面放进去、
   *  不抢焦点,读者正在看的那一页因此活下来,调用方改用未读红点提示(见
   *  `clearUnread`)。v0.25.0+ */
  reveal?: boolean
}

/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
interface FileIconDescriptor {
  /** 唯一 id(如 'my-plugin:icons') */
  id: string
  /** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行
   *  而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)——
   *  它们不会匹配真实文件(名为 x.folder 的文件不受影响)。
   *  [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体
   *  names/扩展名与内置 glyph 永远优先于它)。
   *  **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */
  exts?: readonly string[]
  /** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])——
   *  图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */
  names?: readonly string[]
  /** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])——
   *  图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的
   *  目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */
  folderNames?: readonly string[]
  /** priority 高者胜,缺省 0(同级按注册先后) */
  priority?: number
  /** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。
   *  与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是
   *  任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。
   *  open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */
  icon: (path: string, size: number, open?: boolean) => ReactNode
}

图标注册示例(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认):

if (ctx.betterSidebar.features.includes('fileIcons')) {
  ctx.effect(() =>
    ctx.betterSidebar.registerFileIcon({
      id: 'my-plugin:icons',
      exts: ['csv', 'tsv'],
      icon: (path, size) => <MyCsvIcon size={size} />, // 彩色也可以
    })
  )
  ctx.effect(() =>
    ctx.betterSidebar.registerFileIcon({
      id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开
      names: ['package.json', 'Dockerfile'],
      icon: (path, size) => <MyBrandIcon size={size} />,
    })
  )
  ctx.effect(() =>
    ctx.betterSidebar.registerFileIcon({
      id: 'my-plugin:folders', // 只认领列出的目录名
      folderNames: ['node_modules', 'src'],
      icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
    })
  )
  ctx.effect(() =>
    ctx.betterSidebar.registerFileIcon({
      id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名)
      exts: ['folder', 'folder-open'],
      icon: (path, size, open) => open === true ? <MyOpenFolderIcon size={size} /> : <MyFolderIcon size={size} />,
    })
  )
  ctx.effect(() =>
    ctx.betterSidebar.registerFileIcon({
      id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件
      exts: [],
      icon: (path, size) => <MyGenericFileIcon size={size} />,
    })
  )
}

消费表面与回退链(由本插件内置消费,插件无需自己接线):

  • 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):fileIcon(path, size) ——具体 names/扩展名注册 → catch-all 全局默认(exts: [])→ DSH 官方图标。
  • 文件树目录行(含根行):folderIcon(path, open, size)——folderNames 命中 → 'folder'/'folder-open' 保留扩展名 → DSH 官方的文件夹图形。

注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 (console.error 后跳到回退链下一级),不会空白行。

内置图标 = DSH 官方图形(v0.19.0+,插件不含任何图标数据):

  • 回退链末端是 FileTypeIcon / CodeFileIcon(@deepseek-ai/dsh-client-ui-primitives, DSH 0.1.5-rc.2+):48 个代码/配置类目的官方全彩图形 + markdown / 图片 / PDF / Word / Excel / PPT / 视频 / 文件夹 / 通用文档的类目色板图形,分类器是宿主的 classifyFileType(精确文件名 → 前缀/后缀 → 项目上下文 → 扩展名)。本插件因此没有 自己的扩展名表、没有图标 chunk、没有图标主题开关——彩色是唯一形态。
  • 语义后果(重要):宿主分类器覆盖任意路径,所以「插件自己已经能画这个扩展名」不再是 拦住 catch-all 的理由——注册了 exts: [] 的插件会接管全部未具体命中的行(优先级降序、 同优先级按注册序)。只想补几个扩展名就照常用 exts/names,别用 catch-all 兜底。
  • 插件自己注册的图标颜色由注册方负责(见 §12):品牌色是内容标识而非 chrome。

版本与能力探测(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:

if (ctx.betterSidebar.features.includes('badge')) {
  // 使用 TabDescriptor.badge
}
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }

生命周期示例(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 onClose:

ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:db',
    title: 'Database',
    single: true,
    badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
    onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
    onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
    component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
  })
)

8. 声明式设置(v0.4.1+)

每个注册的 tab / viewer 自动出现在 DSH 设置页「侧边卡片」分区(SideCardSection 按注册表驱动渲染,无硬编码):

  • 展示:小卡片网格(图标 + 标题 + 类型 id),高亮 = 启用,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
  • 持久化:开关写入 SidebarPrefs.tabsEnabled / viewersEnabled(开放 map,缺省 = 启用,显式 false 才禁用)。
  • 关闭语义:tab 从 + 菜单消失、openTab 拒绝新开(console.warn)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,已打开的 tab 保留;viewer 被 matchFileViewer 跳过,文件落到下一个匹配。
  • settings.toggles(可选):在卡片行下追加嵌套设置行(仅父级启用时显示),绑定 SidebarPrefs 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 SettingRow:type: 'switch' | 'text' | 'number'(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 type: 'select'(v0.13.0+;options 支持 value/title/desc/icon,multi 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)与 type: 'patterns'(可编辑字符串数组——chips + 删除钮 + 常驻添加输入框,add/remove 均原子提交整表,占位符走 patternsPlaceholder,值必须存 string[];能力 gate settingPatterns)。内置示例:subagent tab 的 autoOpenSubagent、editor tab 的 editorExplorer 图标化下拉(工作区路径围栏与它的开关已移除,见 §8.1)与 explorerExclude 排除模式行。
  • settings.pluginToggles(可选,v0.12.0+):插件自有设置行,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 pluginSettings[<descriptor id>](开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。
  • settings.render(可选,v0.12.0+):自定义设置面板——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、updatePluginSetting(key, value) 与 close();抛错会被吞掉并显示内联错误。
ctx.effect(() =>
  ctx.betterSidebar.registerTab({
    id: 'my-plugin:db',
    title: 'Database',
    order: 50,
    settings: {
      // 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
      toggles: [{
        key: 'autoOpenSubagent',   // 宿主内置键
        title: 'Auto-open',
        desc: 'Open when a subagent appears',
      }],
      // 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db']
      pluginToggles: [{
        key: 'pageSize',
        title: 'Page size',
        type: 'number',
        min: 1,
        max: 100,
        unit: 'rows',
      }],
      // 或完全自定义面板(追加在行列表之后)
      render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => (
        <MySettingsPanel
          values={pluginSettings}
          onChange={(key, value) => { updatePluginSetting(key, value) }}
          onDone={close}
        />
      ),
    },
    component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
  })
)

⚠️ toggles 的 key 必须是本插件 PrefsSchema 的字段(PrefsSchema 已并入本插件 Loader 行的 Config;内置键:autoOpenSubagent / autoOpenJobs / tasksViewMode / mobileNoAutoOpen / mobileDefaultTree / agentOpenTools / editorExplorer / editorGitGutter / explorerExclude / customCss / htmlViewerNoSandbox / htmlViewerDefaultUnsafe / tabsEnabled / viewersEnabled / pluginSettings;已删除:workspaceFence(v0.23.0,围栏整体移除,见 §8.1)/ titleBarScheme / titleBarPresetId / titleBarCompat / titleBarStripPx(titleBar 让位机制整体移除,见 §12.1)/ agentTerminalTools / terminalShell / terminalShellArgs / terminalFontFamily / terminalFontSize / bottomPanelAutoTerminal / browserNoSandbox / browserAllowedLoopback / 三个按协议分流的旧外链接管键)。v0.12.0 起设置 seam 已开放:你自己的设置走 pluginToggles(声明式行)或 render(自定义面板),值持久化在 pluginSettings[id]——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。

8.2 宿主设置表单(DSH 0.1.7+:SettingsForms)

0.1.7 把可注册的设置命名空间整体删除了。 ctx.settings.register(ns, schema) 与它返回的 get / watch / update / replace 都已不存在,取而代之的是 SettingsForms——一套按 profile 条目寻址的表单服务:

interface SettingsForms {
  /** 只列出「导出了 Config 且 fiber 处于 ACTIVE」的条目;ns 就是 Loader 行的 entry id。 */
  describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
  /** 把 patch 合并进该条目的 config(revision 不符时抛 SettingsConflictError)。 */
  update(ns: string, patch: object, expectedRevision?: number): Promise<void>
  /** 重置全部 live 字段后再写入给定字段(普通 config 保留)。 */
  replace(ns: string, section: object, expectedRevision?: number): Promise<void>
  /** 按路径逐字段改(持不完整视图——例如被脱敏的秘密字段——时用它,别用 replace 重述整节)。 */
  mutate(ns: string, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>
  /** 声明本插件实例的页面策略(自带设置页的插件写 auto: false);返回 disposer。 */
  configure(presentation: { auto?: boolean }, owner?: Fiber): () => void
  get writable(): boolean
  get documentPath(): string
  prepareDocument(): Promise<string>
}

interface SettingsDescriptor {
  ns: string          // profile 条目 id —— 不是插件自选的命名空间
  autoGenerate: boolean
  schema: unknown
  value: unknown      // 已解析(defaults → composition base → 用户层)
  revision: number
  base?: unknown
  user?: unknown      // 只读的 profile 覆盖层;为空 = 该条目还没被用户写过
  applies: 'live'
  secrets?: RedactedSecret[]
}

四条必须记住的新规则:

  1. 命名空间 = 插件 Loader 行的 entry.options.id,不是插件自选的字符串。聚合包会用别的 id 挂同一个包,所以运行时自发现(本插件 src/index.ts 的 ownEntryId(ctx):有效 id 的行优先按 entry.fiber === ctx.fiber 匹配,支持别名与 file: 挂载;未找到自己的 fiber 才回退到第一个启用的同包名行),不要硬编码 id。
  2. schema 来自插件模块导出的 Config(entry.fiber.runtime.Config)。用户可见的字段必须并进这个 Config——本插件把 PrefsSchema 合并进 Config(src/config.ts),并把每个偏好字段标 meta.volatile = true;这一个标记就是「改设置实时生效、不重挂插件」的全部机制(Loader 的 equalExceptVolatile 跳过 volatile 字段,纯 volatile 变更提交进运行中的引用并发 loader/volatile-update,普通字段仍是重挂语义)。
  3. 自带设置页的插件要主动退出自动表单:ctx.effect(() => ctx.settings.configure({ auto: false }, ctx.fiber)),否则 Settings 会把同一批字段渲染两遍(策略不影响读写)。
  4. settings/document-updated 你监听不到,别写:事件签名是 'settings/document-updated'(ns, revision)(语义是「该条目的表单值 / 可用性 / 页面策略变了,表单客户端重新读取 schema、解析值与 revision」),但它在 settings 服务自己的 context 上 emit,而 cordis 事件只向该 ctx 的祖先冒泡——插件 fiber 是兄弟,ctx.on(...) 永远不会触发。替代做法:每次重新读取表单时重新求值(本插件的 settingsFace.get() 在返回当前值前跑一遍门控同步;客户端侧另经 remote 服务的 $on('settings/document-updated') 镜像触发重新拉取)。旧文档里的 settings/updated 事件在 0.1.7 已不存在。

8.1 工作区包含检查已移除(v0.23.0,权限放开)

workspaceFence 这个键已经不存在了,也没有替代开关。 侧栏的文件系统路由(fs.tree / fs.trees / fs.read / fs.write / fs.rename / fs.remove / fs.mkdir / /sidebar/file 媒体 / /sidebar/html 预览 / /sidebar/upload / archive.build)不再做任何工作区包含检查:路径只做词法解析(会话相对路径拼到会话 cwd 下、resolve() 折叠 ..),不再 realpath、不再比对前缀、不再有 403 forbidden 分支。符号链接被照常跟随。

⚠️ 安全声明(必读)

这是一次权限放开:插件侧边栏的 fs 路由现在能读写宿主用户能访问的任意路径,只受 OS 权限约束。也就是说,一个能访问到本插件 /sidebar/* 路由的同源页面/脚本(包括你写的第三方消费插件)可以借这些路由读取、改写、删除该用户权限内的任何文件——例如 ~/.ssh/、~/.dsh/、其他项目的源码。

缓解手段只有两块,别指望有第三块:① 每条 HTTP/WS 路由都过宿主的浏览器信任与认证栅栏(ctx.connection.requestRejection(req),见 §6.2),所以前提是攻击面已有宿主认可的来源与认证;② 宿主用户的 OS 权限本身的边界。不要再把「工作区围栏」当成安全边界来设计你的插件——它已经不在了。

历史背景(< v0.23.0):旧的实现用 realpath 解析后比对工作区前缀,越界回 403,并有一个 workspaceFence 开关(默认开)可临时关闭;开启时编辑器/文件树错误面会显示原因 + 一键关闭按钮(FenceErrorNotice)。用户要求删除该检查,上述代码路径随之删除(src/path-security.ts 现在是纯词法解析)。旧 profile 里遗留的 workspaceFence 键只是未知键——schemastery 的 object schema 对未知键是容忍的,既不报错也不再有任何效果。


9. 生命周期与 HMR

  • disposer 必须返回并被 fiber 持有:registerTab / registerFileViewer 返回 () => void,Cordis fiber 卸载时自动调用。务必用 ctx.effect(() => register(...)) 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 "already registered" 错误。
  • 注册时机:better-sidebar 在 apply() 开头 ctx.provide('betterSidebar', service),你的 inject = ['betterSidebar'] 保证你激活时服务已就绪;注册顺序无关紧要。
  • 持久化降级:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 <OrphanedTab/> 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(sanitizeNode 保留未注册类型而非丢弃)。
  • visible 语义:面板折叠或非激活 tab 的 visible 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。

10. 平台约束与陷阱

陷阱 说明
构建纯度门 client bundle 禁止 value-import @dsh-external/* 或非白名单的 @deepseek-ai/*(tsdown.config.ts 的 dsh-client-bundle-purity 插件);类型 import type {} 会被擦除,不触发门禁——类型可自由共享,运行时符号不行。所有跨插件交互走 ctx.betterSidebar 方法调用
统一 cordis 分支(v0.15.2+) 类型基底与 augmentation 全部在 DSH 运行时正主 @deepseek-ai/cordis 上(src/context-types.ts:真实 cordis Context 与结构化服务面做交集,不再重述 declare module 'cordis'——host/client 包对同名成员声明不同会 TS2717)。消费者 import type {} from 'dsh-better-sidebar' 即拿到 ctx.betterSidebar,或直接 import type { Context } from 'dsh-better-sidebar'。公开版 cordis 不再被依赖
ModuleLoader 不跨插件 运行时 require() 虽支持跨 bundle,但被构建门挡;所有交互走 ctx.betterSidebar 方法调用
host 半无此服务 ctx.betterSidebar 只在 client 侧存在;host 半需要 better-sidebar 数据走 /sidebar/api/* HTTP 路由
portal 限制 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板
id 冲突 registerTab / registerFileViewer 对重复 id 抛错;建议用包前缀(my-plugin:xxx)
不要 value-import dsh-better-sidebar 即使是 ./client/service 子路径,运行时落点也是 client bundle;只做 type-only import
client 声明图零 Node 依赖(v0.12.0+) dsh-better-sidebar/client/service 的可达声明面(含 Context)不引用 node:* / Buffer——纯浏览器侧插件无需 @types/node,skipLibCheck: false 也能编译(scripts/check-consumer-types.sh 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境)
家族右面板互斥(v0.13.0+) aionui-panel 设置的 rightPanel 解析为 'aionui-panel' 时整个侧边栏不挂载(settings.get 返回 externalDisable: true,挂载门 + 接管停用)。DSH 0.1.7 起 settings/updated 事件已删除,保留的 settings/document-updated 是在 settings 服务自己的 context 上 emit 的、插件 fiber 监听不到(见 §8.2 第 4 条)——所以实时性由「每次重新读取时重新求值 + 客户端经 remote 镜像重新拉取」提供,不要写成 ctx.on('settings/document-updated')
设置持久化位置(0.1.7 起) 偏好不再写 ~/.dsh/settings.yaml,而是落在 profile 的 cordis patch 文档里,按插件 Loader 行的 entry id 组织(本插件默认 better-sidebar)。0.1.6 线的 dsh-better-sidebar 段由插件在首次启动时一次性回迁(只在该行用户层仍为空时执行,且只迁移当前 Config 仍声明的字段),所以用户设置不会丢。排障时先看 profile patch,而不是旧的 settings.yaml(服务已把它改名为 settings.yaml.imported)
i18n 跟随 文案跟随 DSH ctx.locale(词典在 betterSidebar 命名空间;Host-backed locale.preference 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件不要依赖内部 t()——标题传字符串或 () => string
第三语言覆盖(ja 等) 可选 peer @huanlin/dsh-plugin-better-locale(optional)提供 ja/ko 覆盖,借用 DSH 英文槽位(仅 DSH=en 时生效,zh 下惰性)。经 ctx.get('betterLocale') 注入 t();未安装整段 no-op
懒加载 chunk 重依赖(CodeMirror / mermaid / 19 份第三语言词典)在独立 bundle(lib/client-<name>.js,现存 editor / mermaid / locale 三个),经 /sidebar/bundle 按需下发;factory 赋到 globalThis.__dshChunks__[<name>],由 src/client/chunk-loader.ts 物化,不经 __ModuleLoader__。对消费插件透明
聊天文件打开漏斗(0.1.5 宿主) 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))(packages/client/ui-chat/src/client/apply.ts 是唯一调用点);remote.session.openWorkspacePath 在 0.1.5 客户端已无调用者,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 dsh-resource://file/**(见 AGENTS.md §3 第 10 条);不要假设 ctx.workspaces.openPath(已删除)或旧 remote.session 漏斗存在
原生 tab 体的高度契约 你的 tab 组件被挂在全高列 flex 宿主里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 height:100% 的 sidebar.module.css .nativeTabHost(带 data-dsh-native-tab-host)——根用 flex: 1 / height: 100% 并加 min-height: 0 填满面板。宿主的面板体 .paneBody(dsh-client-ui-dockkit)是有确定高度的块级滚动容器,不是 flex 容器,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(ui-sidebar-documentpreview 的文档预览、ui-sidebar-files 的 FilesBody)正是因此在根上声明 height: 100%

11. 自由窗口(v0.16.0 – v0.18.x,v0.19.0 已移除)

自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,SidebarState.floats、floatTab/dockFloat/raiseFloat、features 里的 'floatWindows'、右键「移动到自由窗口」、src/client/FreeWindow.tsx)在 v0.19.0-alpha.0 整体删除:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。

  • 升级到 v0.19.x 后请不要再 gate features.includes('floatWindows'),也不要引用 floatTab 等符号(类型声明里已不存在);
  • 旧文档里持久化的 floats 字段会被 sanitizeState 直接忽略,会话不会因此加载失败;
  • 需要「同一个 tab 在别处看」的场景,改用原生栏打开(openTab 默认落点;同一文件再开一格用 target: 'side')或底部工作台分栏(OpenTabSeed.target: 'bottom')。

12. 皮肤兼容(令牌驱动)

better-sidebar 所有视觉值消费 DSH 的 --dsw-alias-* / --dsw-font-* / --ds-* 令牌(无硬编码颜色),不做每皮肤适配。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 --dsw-alias-* 层;tests/theme.spec.ts 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。

没有任何豁免面:文件与文件夹图标是 DSH 官方的 FileTypeIcon 图形(宿主自己 的调色板,插件不画像素也不存数据),插件画的每个 glyph(含内置 tab 的彩色图标)颜色 都来自 --dsw-alias-*——tests/theme.spec.ts 同时守护「图标模块零颜色字面量」与 「样式表里每条 color 都解析到令牌」。插件自己注册的图标(registerFileIcon)颜色由 注册方负责,不受本节令牌约束,但不要把彩色图标数据塞进核心 bundle 的常驻渲染路径 (要按需加载就照 src/client/chunk-loader.ts 的懒加载 chunk 走)。

12.1 规则

  • 面板表面:右/底面板背景 = var(--dsw-alias-bg-layer-1)。绝不消费 --dsw-specific-sidebar-fill(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 --dsw-alias-bg-layer-1。

  • 终端/编辑器表面:effectiveTokenValue 读 --dsw-alias-bg-base——transparent 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。

  • 根锚点:宿主 div 带 data-dsh-better-sidebar(append 到 body);其内面板宿主层 [data-dsh-panel-host](fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 data-dsh-panel-host-degraded 降级。overflow 级联是契约(hidden 兜底 + clip 收尾,tests/panel-host-css.spec.ts 守护):hidden 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);clip 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 [data-dsh-better-sidebar] 内。

  • 布局变量(<html> 上,面板打开时有效):--dsh-sidebar-width / --dsh-sidebar-height。右面板宽度 = AppFrame 的 padding-right 预留(新版 #root [data-dsh-frame] / rc.8 #root > [data-slot="root"] > div 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol margin-bottom;centerCol 锚点 = JS 标注(禁止 nth-child):侧栏 shell 的定位器给测得的 centerCol 节点打 [data-dsh-center-col] 标签(Sidebar.tsx locate,节点更换/HMR 时随 ref 迁移),layout.css 用 #root [data-dsh-center-col] 选中(drag-layout.e2e.ts 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 #root [data-slot="main.conversation"] 解析进列并跳过 display: contents 祖先(center-column.ts 的 CENTER_COLUMN_SELECTOR 同时认 main.conversation 与 alpha.1 的 conversation),定位器从槽宿主向上取第一个非 contents 的祖先;frame 宽度与桌面 Session Log 由 desktop-layout.e2e.ts 断言)。

  • 用户空间 CSS(v0.14.1+;titleBar 让位机制移除后只剩它):SidebarPrefs.customCss(设置页「自定义 CSS」分组)注入 <style data-dsh-custom-css="custom"> 到 head 末尾(后写胜出;覆盖 JS 内联需 !important),fiber 卸载即移除,非空即生效(不再有方案门控)。稳定寻址面:[data-dsh-toggle-cluster] / [data-dsh-panel] / [data-dsh-bottom-panel]。

    • 已移除:位置兼容模式 / titleBar 让位机制(v0.14.1 引入,本次整体删除,见 docs/plans/2026-10-05-remove-titlebar-compat-strip.md)。历史模型是四方案 titleBarScheme(auto / web / 壳预设 / custom),由 src/client/titlebar-strip.ts 的纯函数按「壳自带 ctx.desktopWindow 契约 → navigator.windowControlsOverlay 真实几何 → URL dsh-desktop-titlebar-inset → 壳预设 → 手动 px」求出顶栏让位高度,写 body[data-dsh-title-bar-compat] + --dsh-title-bar-strip。删除原因:插件自 v0.19.0 起不再自绘顶部 chrome,CSS 里没有任何规则读这两个值,宿主也不读这条契约(用户想移动的侧边栏按钮是宿主原生控件,插件没有它的位置 API),于是改设置只写变量、什么都不动;而设置项描述与现实不符。
    • 对消费插件的影响:不要引用 SidebarPrefs.titleBarScheme / titleBarPresetId / titleBarCompat / titleBarStripPx(toggles 里声明它们会被当成未知键),也不要写 --dsh-title-bar-strip / body[data-dsh-title-bar-compat](无人消费)。桌面壳仍会戳 URL(dsh-desktop-mode / -platform / -titlebar-inset)并提供 ctx.desktopWindow/navigator.windowControlsOverlay,但本插件一律不再读——需要顶部几何的插件请自己探测,注意 ctx.desktopWindow 是可选客户端服务,绝不能写进 dsh.client.inject(普通浏览器没有它,注入会让插件永远 pending)。
    • 旧偏好数据安全:已删的四个键在宿主 schema 里是未知键(OPEN),解析不报错、不写回;客户端 parsePrefs 只按声明字段构造,它们不会进入 UI(tests/prefs.spec.ts / tests/plugin-shape.spec.ts 守护)。
  • 拖拽区退出:交互 chrome(.toggleCluster / .toggleButton / .tabBar)统一 -webkit-app-region: no-drag(无边框壳拖拽带吞点击,#103/#111)。

  • 拖拽区退出(视口层):宿主把每个直挂 body 的子元素都设成 no-drag(html[data-platform=darwin] body > :not(#root),选择器含 id,只能靠 !important 压过),而 app-region 无视 pointer-events——插件宿主就是这样一个 body 子元素,在 Electron 里该值还会传到它内部的铺满视口的面板层(#772 的 CDP 实测;挂载 lane 用的浏览器引擎不传播该属性,所以面板层自己也声明了同一条规则),于是它把下面每条 [data-window-drag] 拖拽带一起抵消(窗口拖一次就失效,同时丢掉 macOS 双击标题栏缩放,issue #772)。契约:装饰性的视口层用中性值 -webkit-app-region: initial !important 退出计算(initial 的计算值 none 不扣减拖拽区;注意字面量 none 不是中性值——它计算成 no-drag,是扣减值),层内的面板/控件再声明 no-drag 保住点击;当前覆盖 [data-dsh-better-sidebar]、[data-dsh-panel-host](> * 保持 no-drag)与 .mermaidModal(放大视图是第二个持久铺满视口的 body 直挂层)。交互弹层不要加 initial(.selectionPopup 是 <button>,FloatingWindow / AnchoredPopover 自带指针拖拽、不是遮罩层):宿主 :is(button, a, input, …) 已给它们 no-drag,反过来声明 initial !important 会让按下变成拖窗(重演 #103/#111)。放大视图是遮罩层例外——它是模态,只有其中的控件必须保持 no-drag;代价是它覆盖在顶部拖拽带上时,点背景会变成拖窗而不是关闭(为「放大图时窗口仍可拖」付的账)。未纳入:FileTree 的 .uploadDropChatHint 同样是 body 直挂的装饰层,但它只在 OS 文件拖拽悬停期间存在且 pointer-events: none,收益极小、暂不处理(FloatingWindow / AnchoredPopover 是交互层,按上面的判据本就该保持 no-drag)。形状由 tests/panel-host-css.spec.ts 在 Linux 的 pnpm test 里守护(无 macOS runner;tests/theme.spec.ts 只守颜色,故本轮不涉及),真实级联由挂载 lane 的探针按宿主规则断言计算值(面板宿主探针复现宿主两条 darwin 规则,放大视图探针直接用页面里宿主自己的规则)。

  • z-index:面板宿主层 25、按钮簇 45——低于 DSH ui-cordis 插件面板(30)与浮层栈(100/1000+),浮层天然盖住侧边栏。

  • 更改行选中态:Git 文件行和本轮操作行保持原有布局;选中只绘制 --dsw-alias-interactive-bg-active 背景,以及行内 inset 4px、宽 2px 的 --dsw-alias-state-business-primary 标记。选中规则与缩进参考线的绝对定位规则必须分开;tests/theme.spec.ts 解析实际 CSS 规则守护,挂载测试验证点击后文件行仍在原分组内。

  • 提交图高度:更改区保持自然高度,提交图区填满固定底栏上方的剩余空间,不设 vh 或固定最大高度。展开的图谱分区以 248px(28px 标题 + 220px 视口)为最小高度参与 flex 分配,避免自动最小内容高度把长历史撑到外层;折叠后只保留标题,长历史在内部滚动;更改列表过长时仍由原外层滚动容器承载。预览区展开或窗口缩放后随可用空间调整,无需额外尺寸观察器。

  • Context 诊断入口与提示:图标沿用宿主 Button 与 IconContextInjectionOutlineRegular,标题栏为固定宽度按钮。跨会话错误通过 shell.overlay 展示一条可关闭提示,背景取 --dsw-alias-bg-layer-3、边框与图标取 --dsw-alias-state-warn-primary、正文取 label 令牌;宽度受视口约束,目标名与错误文案允许任意位置换行。能力中途消失时仍保留本次错误,不依赖来源弹层继续挂载。

12.2 注意事项

  • 类名是 CSS Modules 哈希,不是契约;精确命中用 [data-dsh-better-sidebar] + 子串类名([class*='panel'])或 DOM 结构。
  • 改动本契约必须同步本文档、设计文档与 tests/theme.spec.ts。

13. 完整最小示例

假设插件 my-plugin 要加一个 "Database 浏览器" tab + .csv 文件预览器。

my-plugin/package.json:

{
  "name": "my-plugin",
  "version": "0.1.0",
  "main": "lib/index.js",
  "exports": {
    ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
    "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "dsh-better-sidebar": "workspace:*",
    "react": "^18.2.0"
  },
  "peerDependenciesMeta": {
    "dsh-better-sidebar": { "optional": true }
  }
}

my-plugin/src/client/index.tsx(CSV viewer 的 custom load 直取 /sidebar/api/fs.read,注意响应 envelope 是 { value },且路由经 hostRouteUrl() 解析——见 §6 的前导斜杠禁忌):

import { createElement } from 'react'
import type {} from 'dsh-better-sidebar'  // 触发 ctx.betterSidebar 类型合并
import type { Context } from '@deepseek-ai/cordis'

export const inject = ['betterSidebar']

export function apply(ctx: Context): void {
  // Database tab(单实例,+ 菜单可见)
  ctx.effect(() =>
    ctx.betterSidebar.registerTab({
      id: 'my-plugin:db',
      title: () => 'Database',
      order: 50,
      dedupeKey: () => 'my-plugin:db',
      component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
    })
  )

  // CSV viewer(custom 策略:自己拉字节 + 解析)
  ctx.effect(() =>
    ctx.betterSidebar.registerFileViewer({
      id: 'my-plugin:csv',
      exts: ['csv'],
      fetchStrategy: 'custom',
      load: async (path, scope) => {
        const res = await fetch(ctx.betterSidebar.hostRouteUrl('sidebar/api/fs.read'), {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ sessionId: scope.sessionId, path }),
        })
        const { value } = await res.json()
        return parseCsv(value.content)
      },
      component: ({ customData, path }) =>
        createElement(CsvGrid, { rows: customData as string[][], path }),
    })
  )
}

function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
function parseCsv(text: string): string[][] { /* ... */ }

注册到 profile:

  1. ~/.dsh/profiles/web/package.json 的 dependencies 加 "my-plugin": "link:<你的插件路径>";
  2. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(- insert: - id: my-plugin / name: 'my-plugin');
  3. 在 profile 目录 pnpm install;
  4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 dsh web;仅 host 半改动需要重启)。

14. 参考实现与调试

better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),调试时直接读:

  • src/client/builtins/:6 个内置 tab(tabs.tsx)+ 3 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts);Office / 表格 / 图片 / PDF 预览不在插件里(宿主的 ui-sidebar-documentpreview 负责,见 §5.4)
  • src/client/service.ts:BetterSidebarService 接口 + createBetterSidebarService 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
  • src/client/Sidebar.tsx:底部工作台外壳 + TabContent 分发(查 getTab → 调 descriptor.component;未注册 → <OrphanedTab/>)、+ 菜单构建(order 排序 + available disabled + 禁用过滤)
  • src/client/native/:原生右侧栏接入(tab 类型注册、合成 SidebarTab 适配、资源地址、跨会话打开排队)
  • src/client/sidebar/bottom-toggle.tsx:底部工作台开合按钮(注册进 DSH 会话头 utilities 槽)
  • src/client/SideCardSection.tsx:声明式设置页(注册表驱动清单 + 嵌套设置行 + 开关持久化)
  • src/client/api.ts:/sidebar API 的封装(复制其 fetch 模式到你的插件)
  • src/client/plugins-tabs.ts / plugins-viewers.ts:推荐插件目录(「添加插件」弹窗数据源;加一条数据即上架,tests/plugin-list.spec.ts 守护)
  • src/client/FileTree.tsx / TreePanel.tsx / src/fs-search.ts / src/search-engines.ts:文件树 / 树面板 / host 文件搜索(fs.search)。查询分三态(#879):普通名字走引擎链——优先探测本机 fd / rg(DSH 自带 ripgrep 优先),缺失或运行失败回退 JS 遍历,两条路径共用同一份 exclude 探针(引擎侧是输出后置过滤:匹配条目不出现、被排除目录不下钻);带分隔符的相对片段(如 records/exp1,名字永不包含分隔符)由 JS 遍历按根相对路径匹配、不进引擎;指名单一目标的查询(绝对路径、~/…、./x、../x)经 resolveTarget(与 fs.read 同一解析原语:~ 展开、会话 cwd 拼接、win32 WSL/dsh-remote 命名空间改写、词法 resolve)stat 直取,命中返回解析后的绝对路径、目标缺失立即空结果(不烧遍历预算,exclude 不作用于显式路径意图)。dirs 让目录命中行导航文件树而不是当文件打开;调试插桩 DSH_SEARCH_DEBUG=1 写 $DSH_HOME/search-debug.log,缺省 ~/.dsh。host 的 listDirectory 给目录行标记 compact(仅一个非 symlink 有效子项;POSIX 隐藏与 explorerExclude 排除条目不计入),client 的 file-tree-compact.ts 把单目录链折叠成 a/b/c 面包屑行并整链展开/折叠;explorerExclude(VS Code files.exclude 风格 glob 子集,见 exclude-patterns.ts;editor 卡片的声明式 patterns 行可编辑)把匹配条目从树与搜索中彻底移除,两个界面共用同一 host 侧编译好的 matcher;host 侧目录缓存按「路径 + 行额 + 模式集身份」分键,改排除列表即换键。测试:tests/fs-tree-compact.spec.ts / file-tree-compact.spec.ts / fs-tree-exclude.spec.ts / exclude-patterns.spec.ts / fs-search.spec.ts / search-engines.spec.ts
  • src/client/markdown-html.ts / MarkdownHtml.tsx / md-toc.tsx:markdown 内嵌 HTML 管线与目录大纲(注意 md-toc.tsx 头注释的「子组件读父 ref 为 null」时序陷阱)
  • src/agent-opens.ts / /sidebar/ws/agent-opens:模型主动打开(sidebar_open 工具 + agentOpenTools 设置,默认关闭);文件夹窗口 = meta.dir: true 的 editor tab(设计文档)
  • tests/service.spec.ts / tests/builtins.spec.ts:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating;内置清单断言(6 tab + 3 viewer + 声明式元数据,并显式断言 image / pdf / binary-download 不在内置清单里、但作为公开契约仍可被第三方注册)
  • src/fs-watch.ts / src/client/use-dir-watch.ts:文件树实时刷新(按展开目录 watch,150ms 去抖、每连接 64 个句柄上限;tests/fs-watch.spec.ts 守护)
  • docs/plans/:逐特性设计文档(含实施偏差记录,以现状为准);入口如 2026-08-11-service-registry-design.md / 2026-08-11-declarative-sidebar-settings-design.md

15. 真实接入案例

第一个通过 ctx.betterSidebar 接入的三方插件:dsh-sentinel —— 条件驱动的 agent 唤醒系统(文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话)。

  • 接入方式:可选软依赖——client half 本地重述最小服务契约(registerTab),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
  • 注册内容:dsh-sentinel:watches tab(order 60,单实例):全服务器监控表 + 最近触发历史;
  • 类型处理:未 value-import dsh-better-sidebar,构建零耦合;与 §2 的 import type {} 方案可互换;
  • 实测:v0.3.0 起,真实 web profile 验证通过。

通过 ctx.betterSidebar 的三方插件 dsh-sidebar-qa —— 基于 better-sidebar 的划选提问:对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题);快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档。

更多插件接入后欢迎在此登记(一句话 + 链接)。