Compare commits

..
28 Commits
Author SHA1 Message Date
Atlas Zeng cde107ce9e Merge pull request #27 from omdsh-dev/fix/terminal-open-zero-size
fix: 终端延迟到容器有尺寸后再 open(零尺寸初始化崩溃,WKWebView 底部面板空白,issue #25)
2026-08-14 15:28:47 +08:00
Menghuan1918 0c254a2197 docs: README 末尾新增友情链接(dsh-tianshu-tui,双语同步) 2026-08-14 15:03:03 +08:00
Menghuan1918 c5fda3716d docs: README 增加最近更新块(v0.11 要点 + #25 修复),精简功能一览(双语同步) 2026-08-14 14:59:44 +08:00
Menghuan1918 37d91befdb fix: 终端延迟到容器有尺寸后再 open(零尺寸初始化崩溃,WKWebView 底部面板展开后终端空白,#25) 2026-08-14 14:53:03 +08:00
Menghuan1918 d83490c93e feat: 终端二级设置支持自定义字体(字体族 + 9–32px 字号,实时生效)
- SidebarPrefs 新增 terminalFontFamily(空 = 跟随主题等宽字体)/ terminalFontSize(9–32,默认 13),PrefsSchema 与 parsePrefs 同步校验
- 声明式设置 API 扩展:SidebarSettingToggle 支持 type: 'switch' | 'text' | 'number'(缺省 switch,向后兼容)+ min/max/placeholder/unit
- 终端卡片齿轮弹窗新增「终端字体」「终端字号」两行:blur/Enter 提交、number 钳制、提交失败回滚草稿
- TerminalView 经 store 订阅实时应用字体(options + re-fit + resize),懒加载 chunk 无新增依赖
- 新增 resolveTerminalFont 纯函数(回退链:用户字体 → 主题 token → 内置等宽栈)并单测
- 版本 0.11.0,README/AGENTS.md/设计文档同步
2026-08-14 14:36:49 +08:00
Menghuan1918 96064c31a9 docs: DSH 对 client 改动热加载,更新各处「重启 DSH」说明为硬刷新即可(仅 host 半改动需重启) 2026-08-14 14:07:55 +08:00
Menghuan1918 047f48337d feat: 侧边卡片设置页视觉重构——分组容器卡片、计数徽标、自定义开关、加宽设置弹窗(描述 + 完成按钮),卡片齿轮与勾选徽标纵向对齐同尺寸 2026-08-14 14:07:52 +08:00
Atlas Zeng efb2e2bd58 Merge pull request #17 from omdsh-dev/release/v0.10.3
release: v0.10.3 — turnTail 拦截修复 + 安装体验升级(新增 Windows 一键脚本)
2026-08-14 01:04:27 +08:00
Menghuan1918 97e52b650e docs: 安装章节折叠技术细节为可折叠块,版本引用更新为 0.10.3 2026-08-14 00:59:02 +08:00
Menghuan1918 3cbf43854b docs: README_EN 同步安装章节(PowerShell 一键命令 + 分步手动 + 故障排查表) 2026-08-14 00:49:01 +08:00
Menghuan1918 e128c303bc feat: 新增 Windows PowerShell 一键安装脚本 install.ps1,README 安装章节两平台同步
- scripts/install.ps1:官方 CLI 方式一键安装(PowerShell 5.1+/pwsh),
  与 install.sh 对齐:预写 allowBuilds + minimumReleaseAgeExclude(幂等)、
  执行 dsh plugin add、幂等清理旧手动挂载行;支持 -Version/-Restart/-DryRun。
- scripts/install.sh:加固 — -h/--help、Windows Git Bash 下 DSH_HOME 回退
  $USERPROFILE、npm/pnpm view 双通道解析最新版本、失败提示细化、挂载行
  清理改为逐块扫描(删除注释块跟随)。
- README.md:安装章节改为「① 前置 → ② 对应平台一键命令 → ③ 重启硬刷新」,
  macOS/Linux 与 Windows PowerShell 双命令示例。
- package.json:files 列表纳入 scripts/install.ps1。
2026-08-14 00:48:44 +08:00
Menghuan1918 8bf6d1d7ba chore: v0.10.3 — 发布 turnTail 拦截 slots.inject 修复(issue #15) 2026-08-14 00:41:10 +08:00
Menghuan1918 0f9af851ae fix: 聊天产出文件行拦截改用 slots.inject 延迟注册(issue #15)
conversation.chat.turnTail 是宿主 ui-conversation 在 conversation.chat.node
children 表中声明的子插槽,直接 slots.register 会早于声明而抛
"not declared (a parent entry's children table must declare it)",
导致拦截注册失败回退默认 deliverables 行为并弹诊断条。

改用 ctx.slots.inject 等待声明落账后再注册(与官方
dsh-client-ui-deliverables 同款写法):已声明则同步注册,未声明则
在声明提交时注册,声明撤销自动卸载、重声明重新注册,fiber 卸载
幂等清理。editor tab 禁用门控与 openInSidebar 座位不变。

新增 tests/turn-tail-intercept.spec.ts 覆盖已声明/待声明/提前销毁
三种注册时机、editor 禁用回退与 openInSidebar 座位接线。
2026-08-14 00:41:08 +08:00
Menghuan1918 3d88752eb1 docs: 供应链策略说明改为「命令链无法完全规避,24h 后自动消失」 2026-08-14 00:21:01 +08:00
Menghuan1918 1b7c8ba497 docs: 安装置顶一键脚本;手动方式改为 npx dsh 命令链(自愈构建脚本拦截) 2026-08-14 00:12:25 +08:00
Menghuan1918 5a93f610d8 docs: 官方 CLI 安装默认最新版(不带 @版本) 2026-08-13 23:48:56 +08:00
Menghuan1918 9fdb44d9f9 feat: 支持官方 CLI 一行安装(dsh plugin add dsh-better-sidebar)
- package.json 声明 dsh.bundle.patch(./cordis.patch.yml),随包发布挂载
  patch;dsh plugin --profile <name> add dsh-better-sidebar@<v> 自动完成
  安装 + 注册到 dsh.profile.bundles + 下次启动挂载(无需手写挂载行)
- cordis.patch.yml:bundle 挂载层(insert better-sidebar)
- scripts/install.sh:改走官方 CLI;预写 allowBuilds(node-pty/protobufjs)
  规避 pnpm 11 strict-dep-builds 非零退出;幂等移除旧手动挂载行防双挂载
- README(EN):安装置顶官方 CLI 一行命令

v0.10.2
2026-08-13 23:19:05 +08:00
Menghuan1918 e515d74f77 chore: v0.10.2 — 发布 [data-slot] 布局修复 2026-08-13 22:54:19 +08:00
Menghuan1918 0a07cff6a3 fix: 适配 DSH 0.1.x 的 [data-slot] DOM 结构,恢复 Session log 挤压与布局
DSH 0.1.x 把 slot 宿主包进 [data-slot] 容器:
#root > div[data-slot=root] > div.frame > div.centerCol
  > div[data-slot=conversation] > div.root[data-phase]
    > div[data-slot=conversation.session.header] > header

旧选择器 #root > div > div:nth-child(2) 选中的是 data-slot=root 包装
层的第 2 子元素(不存在,其唯一子元素是 frame),导致:
- 折叠态 header 右 padding 挤压失效(Session log 胶囊被按钮簇盖住)
- 底部面板纵向挤压 / 拖动过渡 / 减少动效全部失效
- Sidebar.tsx 中间列定位器(#root > div 的 children[1])失效,
  底部面板宽度测量回退为 0

修复:全部改用 [data-slot] 语义锚点——
- 中间列:#root > div[data-slot=root] > div > div:nth-child(2)
- header:body[data-dsh-sidebar-collapsed]
  [data-slot=conversation.session.header] > header
- Sidebar.tsx 定位器:#root [data-slot=conversation] 的 parentElement
  (= centerCol,探针实测确认)

v0.10.1
2026-08-13 22:53:31 +08:00
Menghuan1918 a85d1ab004 feat: v0.10.1 — 新增一键安装脚本 scripts/install.sh,README 安装置顶 npm 方式
- scripts/install.sh:把插件以 npm 包形式装进 web profile(幂等写依赖 + 挂载行 + pnpm install;支持版本参数 / --restart / --dry-run)
- 版本 0.10.0 -> 0.10.1(修正 npm 元数据 repository URL 到 omdsh-dev)
- scripts/install.sh 纳入发布 files
2026-08-13 22:42:26 +08:00
Menghuan1918 b9f8eea56d docs: 安装方式改为以 npm 包为主(源码/plugin-registry 降为可折叠可选) 2026-08-13 22:33:41 +08:00
Menghuan1918 72ba010ab7 docs: README 增加 npm 安装方式(dsh-better-sidebar@0.10.0) 2026-08-13 22:29:49 +08:00
Menghuan1918 1b7014b2d4 docs: 仓库迁移至 omdsh-dev/DSH-better-sidebar,更新 URL 引用 2026-08-13 22:29:20 +08:00
Menghuan1918 64c6aa477e chore: v0.10.0 npm 发布准备
- @deepseek-ai/* 依赖换为实际发布版本(dsh-* ^0.1.0-rc.6、cordis ^4.0.1),
  补齐 dsh-client-locale peer/dev 声明(对齐 dsh.client.inject)
- pnpm-lock.yaml 重新生成(移除旧 0.0.1-rc.1 图;锁文件不再引用未公开的 dsh-type-meta)
- pnpm-workspace.yaml 补 minimumReleaseAgeExclude 新版本条目
- src/context-types.ts 增加 @deepseek-ai/cordis 双 augmentation
- package.json 发布脚手架:publishConfig.access、prepublishOnly、files 含 README_EN/LICENSE
- README 版本注释同步(^0.1.0-rc.6 / dsh@0.1.0-rc.6)
2026-08-13 22:25:20 +08:00
Menghuan1918 5f6d4c97d5 docs: 快捷键移至安装之后,头部介绍区改用 HTML 徽标样式排版 2026-08-13 20:28:05 +08:00
Menghuan1918 58d6167b69 docs: README 顶部介绍区改为 HTML 居中排版并新增多语言跳转 2026-08-13 20:23:20 +08:00
Menghuan1918 23b1b564d7 docs: 新增英文 README_EN.md,优化安装指引(@deepseek-ai/* 已公开、plugin-registry 折叠) 2026-08-13 20:19:56 +08:00
Menghuan1918 b31a9a601f fix: md 预览代码块复制按钮跟随 DSH i18n(MarkdownText 补传 codeLabels) 2026-08-13 10:26:57 +08:00
41 changed files with 4135 additions and 1460 deletions
+3
View File
@@ -146,3 +146,6 @@ lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
.npmrc
# stray npm cache scratch dir
.npm-cache-tmp/
+19 -7
View File
@@ -127,15 +127,27 @@ interface TabDescriptor {
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
key: string
title: string | (() => string)
desc?: string | (() => string)
/** 行控件类型;缺省 'switch'(向后兼容)。 */
type?: 'switch' | 'text' | 'number'
/** number 行的提交钳制下限。 */
min?: number
/** number 行的提交钳制上限。 */
max?: number
/** text 行的输入占位符。 */
placeholder?: string
/** 输入框后的单位后缀(如 'px')。 */
unit?: string
}[]
}
/** 渲染函数 */
@@ -384,7 +396,7 @@ interface BetterSidebarService {
}
```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
---
@@ -407,7 +419,7 @@ interface BetterSidebarService {
| **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`) |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好) |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
---
@@ -484,7 +496,7 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /
function parseCsv(text: string): string[][] { /* ... */ }
```
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
---
@@ -494,7 +506,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
+187 -67
View File
@@ -1,31 +1,194 @@
# dsh-better-sidebar
> 一个插件,一套完整工作台:
>
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、后台任务页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
>
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
<!-- 头部介绍区(HTML 排版) -->
<div align="center">
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
</div>
<div align="center">
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
</div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## 🆕 最近更新
- **终端自定义字体**:设置页终端卡片新增字体族 + 9–32px 字号设置,实时生效
- **侧边卡片设置页重构**:分组容器卡片、计数徽标、自定义开关、加宽设置弹窗
- **修复**:WKWebView 下底部面板展开后终端空白(xterm 零尺寸容器初始化崩溃,[#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
- **文档**:client 改动硬刷新即可生效,无需重启 DSH
## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止)
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,Office / 终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
```
**Windows(PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
<details>
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
```
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
</details>
<details>
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
**macOS / Linux(bash)**:
```sh
cd ~/.dsh/profiles/web
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
pnpm approve-builds --all
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.10.3)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows(PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① 放行构建脚本
pnpm approve-builds --all
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ 安装并自动挂载
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>脚本内部做了什么(技术细节)</b></summary>
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.10.3` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
</details>
<details>
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
```
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
</details>
<details>
<summary><b>常见问题</b></summary>
| 现象 | 原因与解决 |
|---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
</details>
<details>
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.3"` 再 `pnpm install`。
</details>
<details>
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
</details>
## ⌨️ 快捷键
@@ -38,51 +201,6 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🚀 安装
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
```text
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
1. 克隆并构建:
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
2. 注册到 web profile:
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 执行 pnpm install
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
### 更新
```text
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
```
### 通过 plugin-registry 安装(可选,与上述二选一)
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
## 🔌 服务化:注册 tab 与文件预览器
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
@@ -102,9 +220,7 @@ export function apply(ctx: Context) {
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
@@ -131,3 +247,7 @@ pnpm watch # tsdown --watch
## 🖥️ 平台支持
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
## 🔗 友情链接
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
+253
View File
@@ -0,0 +1,253 @@
# dsh-better-sidebar
<!-- Hero (HTML layout) -->
<div align="center">
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
<code>File management</code> <code>Edit &amp; preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
</div>
<div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## 🆕 Recent Updates
- **Custom terminal fonts**: font-family + 9–32px size settings on the terminal card, applied live
- **Side card settings redesign**: grouped container cards, count badges, custom toggles, wider settings dialog
- **Fix**: blank terminal after expanding the bottom panel on WKWebView (xterm initialized in a zero-size container, [#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
- **Docs**: client changes take effect on a hard refresh — no DSH restart needed
## ✨ Features
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (Office / terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md))
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**macOS / Linux** (also works in Git Bash / WSL on Windows):
```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
```
**Windows (PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
```
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
<details>
<summary><b>Pin a version / auto-restart (optional)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
```
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
</details>
<details>
<summary><b>Manual install (step by step)</b></summary>
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
**macOS / Linux (bash)**:
```sh
cd ~/.dsh/profiles/web
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
pnpm approve-builds --all
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.10.3)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows (PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① Allow build scripts
pnpm approve-builds --all
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ Install and auto-mount
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>What the script does (technical details)</b></summary>
The one-click script does four things, all idempotent (safe to re-run):
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.10.3` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
</details>
<details>
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
```
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details>
<details>
<summary><b>Troubleshooting</b></summary>
| Symptom | Cause & fix |
|---|---|
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (the one-click script handles it). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
</details>
<details>
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
4. Run pnpm install in ~/.dsh/profiles/web
5. Restart DSH and hard-refresh
```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.3"` and re-run `pnpm install`.
</details>
<details>
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
dsh registry install ./registry # install (disabled by default)
dsh registry enable dsh-external/dsh-better-sidebar
```
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
</details>
## ⌨️ Keyboard Shortcuts
| Action | Keys |
|---|---|
| Save edits | `Ctrl/Cmd + S` |
| Git commit | `Ctrl + Enter` |
| Close tab | Middle mouse button |
| Split / merge panes | Drag tab to pane edge / middle |
| Reference file to input | Hover the `@file` button at end of line |
| Copy file path | Right-click row → copy relative/absolute path |
## 🔌 Service: register tabs & file viewers
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 9 viewers go through the same service, eating our own dog food):
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings): see [`AGENTS.md`](./AGENTS.md).
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
pnpm watch # tsdown --watch
```
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
## 🔐 Security
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
## ⚠️ Known Limitations
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
- Dragging a terminal tab to another pane remounts it (shell restarts)
- `.xlsx` preview does not preserve cell styles (SheetJS community-edition limitation); Office/PPTX preview is inlined into the client bundle (~23MB), slower on first load
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
- HTML preview renders the saved file (not unsaved drafts)
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
## 🖥️ Platform Support
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
## 🔗 Friends
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
+19
View File
@@ -0,0 +1,19 @@
# dsh-better-sidebar bundle patch
#
# This file is the `dsh.bundle.patch` layer of the published npm package: when
# the plugin is installed through the official CLI —
#
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
#
# — the command reconciles `dsh.profile.bundles` against installed packages
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
# stack. The profile boot then merges THIS patch (a single `insert` of the
# plugin row) exactly like the manual cordis.patch.yml mount line used
# before. No profile file edits needed — one command installs and mounts.
#
# If the profile still carries the old manual mount line in its own
# cordis.patch.yml, remove it before switching to the bundle channel to
# avoid double-mounting (two Node halves, two sidebars).
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
+1 -1
View File
@@ -598,7 +598,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
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. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
---
@@ -50,6 +50,11 @@ DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
- 无。计划与实施一致。
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
钉住 zh/en 跟随。
## 验收
@@ -0,0 +1,111 @@
# 终端自定义字体(设置页二级设置)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.0
## 1. 目标
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
## 2. 非目标
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
## 3. 现状回顾
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
terminalFontFamily: string // '' = 跟随主题等宽字体
terminalFontSize: number // 9–32,默认 13
}
// prefs-shared.ts
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
```
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
```ts
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
interface SidebarSettingToggle {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
min?: number // number 行的下限(提交时钳制)
max?: number // number 行的上限
placeholder?: string // text 行的占位符
unit?: string // 输入框后的单位后缀(如 'px')
}
```
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
```ts
// terminal-font.ts(纯函数,可单测)
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
export function resolveTerminalFont(prefs, themeFontFamily):
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
```
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
### 4.6 i18n(`locales.ts`)
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
## 5. 测试
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
## 6. 限制与取舍
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
## 7. 实施偏差记录
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
@@ -0,0 +1,90 @@
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.x(issue #25 修复)
## 1. 目标
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
## 2. 非目标
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
- 不 bump 版本号(release 流程负责)。
## 3. 现状回顾
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
- 零尺寸挂载路径真实存在且不止一条:
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
## 4. 设计
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
```ts
export function openWhenSized(
host: HTMLElement,
open: () => void,
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
caf: (id: number) => void = cancelAnimationFrame,
): () => void
```
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
- helper 不 try/catch(保持纯净),异常由调用方处理。
### 4.2 消费点 `src/client/TerminalView.tsx`
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
```ts
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
```
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
## 5. 测试
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
3. 只有一维缺失 → 补齐后 open;
4. cancel 后永不 open、无残留帧、cancel 幂等;
5. host 脱离文档 → 轮询停止、不 open;
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
## 6. 限制与取舍
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
## 7. 实施偏差记录
无(按设计实施)。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.9.0",
"version": "0.11.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+53 -31
View File
@@ -1,9 +1,15 @@
{
"name": "dsh-better-sidebar",
"version": "0.9.0",
"version": "0.11.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
@@ -34,6 +40,9 @@
"node": ">=20"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
@@ -55,10 +64,17 @@
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/types/**/*.d.ts",
"src"
"src",
"scripts/install.sh",
"scripts/install.ps1",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
@@ -67,24 +83,29 @@
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"cordis": { "optional": true }
},
"dependencies": {
"@aiden0z/pptx-renderer": "^1.2.4",
"@codemirror/commands": "^6.10.4",
@@ -121,20 +142,21 @@
"xterm": "^5.3.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
+1334 -1109
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -65,3 +65,66 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1'
- '@deepseek-ai/cordis-plugin-include@1.0.6'
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
- '@deepseek-ai/cordis@4.0.1'
- '@deepseek-ai/cosmokit@1.8.2'
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
- '@deepseek-ai/schemastery@3.18.1'
+242
View File
@@ -0,0 +1,242 @@
# =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法(任选其一):
# # 默认最新版
# irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
# # 指定版本 / 装完重启
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
# # 本地保存后运行
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
# -DryRun 只打印将要执行的操作,不写任何文件。
#
# 环境变量(均可省略):
# DSH_HOME 默认 %USERPROFILE%\.dsh
# REGISTRY 默认 https://registry.npmjs.org
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
# 脚本会幂等移除 better-sidebar 挂载行。
# =============================================================================
param(
[string]$Version = '',
[switch]$Restart,
[switch]$DryRun
)
$PKG = 'dsh-better-sidebar'
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
if ($env:DSH_HOME) {
$DSH_HOME = $env:DSH_HOME
} elseif ($env:USERPROFILE) {
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
} else {
$DSH_HOME = Join-Path $HOME '.dsh'
}
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
function Resolve-Spec {
param([string]$Given)
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
$v = $null
foreach ($tool in @('npm', 'pnpm')) {
if (Get-Command $tool -ErrorAction SilentlyContinue) {
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
if ($v) { break }
}
}
if ($v) { return ([string]$v).Trim() }
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
Warn '若已知版本号,可显式传入:-Version 0.10.2'
return 'latest'
}
return $Given
}
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
function Get-DshCli {
if ($env:DSH_CMD) { return $env:DSH_CMD }
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
return $null
}
# 前置校验
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
}
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 web profile)"
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
$wsScript = @'
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
'@
$wsOut = node -e $wsScript "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
$wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
} else {
Say 'workspace 设置已就绪,跳过'
}
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
} else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
}
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
$addOut = & $CLI @cliArgs 2>&1
$addCode = $LASTEXITCODE
$addOut | ForEach-Object { $_ }
if ($addCode -ne 0) {
Warn 'dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:'
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
exit 1
}
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
$bundles = $pkgJson.dsh.profile.bundles
if ($bundles -notcontains $PKG) {
Warn 'dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
}
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
$mountScript = @'
const fs = require("fs");
const p = process.argv[1];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
'@
$mountOut = node -e $mountScript "$PATCH_YML" 2>&1
$mountCode = $LASTEXITCODE
$mountResult = (($mountOut | Out-String)).Trim()
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
if ($mountResult -eq 'removed') {
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
} else {
Say '无旧手动挂载行,跳过'
}
Say "安装完成:$PKG@$SPEC"
# 步骤 5:重启提示
if ($Restart) {
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '重启 dsh-web(pm2)...'
pm2 restart dsh-web
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
} else {
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
}
} else {
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
}
}
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
# --dry-run 只打印将要执行的操作,不写任何文件。
# -h/--help 打印本帮助。
#
# 环境(均可省略,脚本会自动探测):
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
# =============================================================================
set -euo pipefail
# 帮助请求优先处理
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-better-sidebar 一键安装脚本
用法:bash scripts/install.sh [版本] [--restart] [--dry-run]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
--dry-run 只打印将要执行的操作,不写任何文件
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
EOF
exit 0
fi
done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
PROFILE_DIR="$DSH_HOME/profiles/web"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-better-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
DRY_RUN=false
VERSION_SPEC=""
for arg in "$@"; do
case "$arg" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
-h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$arg" ;;
esac
done
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() {
local given="${1:-latest}"
case "$given" in
latest)
local v=""
if command -v npm >/dev/null 2>&1; then
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -n "$v" ]; then
printf '%s' "$v"
else
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
printf 'latest'
fi
;;
*) printf '%s' "$given" ;;
esac
}
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
dsh_cli() {
if command -v "$DSH_CMD" >/dev/null 2>&1; then
printf '%s' "$DSH_CMD"
elif command -v npx >/dev/null 2>&1; then
printf 'npx -y --package @deepseek-ai/dsh dsh'
else
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
fi
}
# 前置校验
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)"
SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
exit 0
fi
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
exit 1
fi
# 校验 bundle 已注册(挂载生效的判据)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
' "$PROFILE_DIR/package.json" "$PKG"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
fi
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
MOUNT_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
' "$PATCH_YML")"
[ "$MOUNT_RESULT" = "removed" ] \
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|| say "无旧手动挂载行,跳过"
say "安装完成:$PKG@$SPEC"
# 步骤 4:重启提示
if [ "$RESTART" = true ]; then
if command -v pm2 >/dev/null 2>&1; then
say "重启 dsh-web(pm2)..."
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
else
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
fi
else
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
if command -v pm2 >/dev/null 2>&1; then
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
fi
fi
+220 -70
View File
@@ -1,20 +1,28 @@
/**
* Side card settings section chrome (DSH-native tokens throughout):
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
* badge (the settings catalogHeading recipe), opening with a one-line
* intro (the DSH section heading+intro recipe);
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
* title/desc left + control right, hairline separators) whose toggles are
* custom switches — a real checkbox (native semantics and focus) driving
* a styled track/thumb;
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping);
* - a card's on/off state IS its visual state: enabled = brand border +
* tinted fill + a check badge pinned to the far right of the top row,
* disabled = neutral hairline + dimmed text;
* (auto-fill columns — several cards per row, wrapping): icon chip +
* title + type id, the card's on/off state IS its visual state
* (enabled = brand border + tinted fill + circular check badge,
* disabled = neutral hairline + dimmed);
* - features that declare related settings carry a gear corner button
* opening a native Modal whose rows are the DSH settings-row recipe
* (title/desc left + native accent checkbox, hairline separators);
* opening a native Modal (wider than the primitive default) whose rows
* follow the same row recipe with switch controls and a Done footer;
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
display: flex;
flex-direction: column;
gap: 8px;
gap: 14px;
width: 100%;
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
never be clipped by the settings shell: fill the shell's options area
@@ -24,15 +32,55 @@
overflow-y: auto;
}
.section > :last-child {
border-bottom: none;
/* One-line section intro (the DSH section heading+intro recipe). */
.intro {
margin: 0;
padding: 0 2px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
layer-3 fill; holds its own heading + stacked rows / card grid. */
.group {
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px 20px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: var(--dsw-alias-bg-layer-3);
}
/* Group heading: 13/600 + the inventory count badge (the settings
catalogHeading recipe: count in tabular-nums, tertiary). */
.groupHeading {
display: flex;
align-items: baseline;
gap: 7px;
padding: 0 2px 6px;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
.count {
font-size: 12px;
line-height: 18px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 10px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
@@ -40,7 +88,7 @@
position: relative;
display: flex;
flex-direction: column;
border-radius: 8px;
border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
@@ -52,6 +100,7 @@
/* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-label-dimmed);
}
/* Enabled = highlighted: brand border + tinted fill. */
@@ -64,9 +113,9 @@
.cardMain {
display: flex;
flex-direction: column;
gap: 2px;
gap: 6px;
width: 100%;
padding: 10px 12px;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
@@ -85,18 +134,28 @@
.cardTop {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
min-width: 0;
}
.cardIcon {
/* The icon sits in a rounded chip: brand-tinted while the card is on,
neutral (layer-2 fill + hairline) while off. */
.cardIconChip {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-tertiary);
}
.cardOn .cardIcon {
.cardOn .cardIconChip {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
color: var(--dsw-alias-button-primary-fill);
}
@@ -105,6 +164,7 @@
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
@@ -115,12 +175,18 @@
color: var(--dsw-alias-label-primary);
}
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
/* The check badge: a circular brand-filled pin at the card's far right
(enabled only), the icon in the inverted surface token (the .save recipe). */
.cardCheck {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
@@ -136,24 +202,28 @@
color: var(--dsw-alias-label-secondary);
}
/* The gear corner button: bottom-right of cards that declare related
settings; the desc gets right padding so the ellipsis never runs under it. */
/* Cards with a settings gear keep the desc clear of it: the gear sits in
the desc row, sharing the check badge's right edge (a vertical column of
controls at the card's right side). */
.cardWithGear .cardDesc {
padding-right: 18px;
padding-right: 30px;
}
/* The gear corner button: directly UNDER the check badge on the card's
right edge — the two controls are vertically aligned (same right inset,
one row apart) and the SAME 16px size, not side by side. */
.cardGear {
position: absolute;
right: 6px;
bottom: 6px;
right: 12px;
top: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 6px;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
@@ -164,26 +234,21 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
.sectionHeading {
padding: 14px 0 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary);
}
/* Native settings row recipe (the width input row + popup rows). */
/* Settings-row recipe (the DSH General section rows): title/desc left,
control right, hairline separators (stripped on the last row). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
padding: 12px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.row:last-child {
border-bottom: none;
}
.rowText {
display: flex;
flex-direction: column;
@@ -199,45 +264,66 @@
.desc {
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-label-secondary);
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
.toggle {
/* Custom switch: a real checkbox (native semantics + focus, visually
hidden) driving a styled 36x20 track with a sliding thumb. */
.switch {
position: relative;
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
cursor: pointer;
}
.switchInput {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
opacity: 0;
}
.toggle:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
/* Secondary settings popup body: stacked native rows, trailing separator
stripped by the trailing-child rule. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
.switchTrack {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
width: 36px;
height: 20px;
padding: 2px;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
transition: background 0.15s ease, border-color 0.15s ease;
}
.popupRows > :last-child {
border-bottom: none;
.switchThumb {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dsw-alias-label-secondary);
transition: transform 0.15s ease, background 0.15s ease;
}
.switch:hover .switchTrack {
border-color: var(--dsw-alias-label-dimmed);
}
.switchInput:checked + .switchTrack {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-button-primary-fill);
}
.switchInput:checked + .switchTrack .switchThumb {
transform: translateX(16px);
background: var(--dsw-alias-bg-layer-3);
}
.switchInput:focus-visible + .switchTrack {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.control {
@@ -251,12 +337,74 @@
width: 76px;
}
/* Text/number rows in the settings popup: the free-form input (e.g. the
terminal font family) gets a generous width; numeric rows (font size)
keep the compact width of the general number inputs. */
.typedInput {
width: 200px;
}
.typedInputNumber {
width: 76px;
}
.suffix {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
/* Secondary settings popup: a wider dialog than the primitive default (the
dialog portals to document.body; the doubled class keeps the width rule
above the primitive's .dialog regardless of stylesheet order). */
.popupDialog.popupDialog {
width: min(460px, 100%);
}
/* Popup rows: the same settings-row recipe with switch controls. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.popupRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.popupRows > :last-child {
border-bottom: none;
}
/* The popup's Done button: the settings primary-button recipe
(PluginCard .save: label-primary fill, inverted surface text). */
.done {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.done:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
.error {
padding: 10px 0 2px;
font-size: 12px;
@@ -265,7 +413,9 @@
}
@media (prefers-reduced-motion: reduce) {
.card {
.card,
.switchTrack,
.switchThumb {
transition: none;
}
}
+269 -114
View File
@@ -4,21 +4,30 @@
*
* The section is DECLARATIVE — it renders the enable/disable inventory from
* the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (a toggle card), and
* the default panel width as a percent of the window (number input row).
* - 常规: new conversations open the panel by default (a toggle row), the
* default panel width as a percent of the window (number input row), and
* the open-path interception toggle — the DSH settings-row recipe
* (title/desc left + control right, hairline separators).
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
* external plugins alike), laid out in a responsive grid that wraps
* several cards per row — icon + title + type id, clicked to toggle the
* switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
* several cards per row — icon chip + title + type id, clicked to toggle
* the switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
* + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
*
* Every group lives in a container card (the DSH PluginCard recipe: l2
* hairline, 16px radius, layer-3 fill) with a heading and an inventory count
* badge (the settings catalogHeading recipe); the section opens with a
* one-line intro (the DSH section heading+intro recipe).
*
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
* border + tinted fill + a check badge pinned to the card's far right),
* disabled = neutral and dimmed. Features that declare `settings.toggles`
* carry a gear corner button that opens a native Modal with the related
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
* when a subagent appears", the Terminal page's model terminal tools).
* border + tinted fill + a circular check badge pinned to the card's far
* right), disabled = neutral and dimmed. Features that declare
* `settings.toggles` carry a gear corner button that opens a native Modal
* (wider than the primitive default) with the related settings as
* title/desc + custom-switch rows and a Done footer. The toggles
* themselves are custom switches: a real checkbox (native semantics and
* focus) driving a styled track/thumb.
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
@@ -32,8 +41,6 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconCodeOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
Modal,
@@ -106,40 +113,133 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
}
/**
* The body of a feature's secondary settings popup: one native checkbox row
* per declared toggle. Extracted so the rows are testable without opening
* the Modal (the Modal portal renders only while open).
* The custom switch: a real checkbox (hidden, native semantics and focus)
* driving a styled track/thumb. Used by the general toggle rows and the
* secondary settings popup rows.
*/
function Switch(props: {
checked: boolean
onChange: (next: boolean) => void
label: string
}) {
const { checked, onChange, label } = props
return (
<label className={css.switch}>
<input
type="checkbox"
className={css.switchInput}
checked={checked}
aria-label={label}
onChange={event => { onChange(event.currentTarget.checked) }}
/>
<span className={css.switchTrack} aria-hidden="true">
<span className={css.switchThumb} />
</span>
</label>
)
}
/**
* The body of a feature's secondary settings popup: one row (title/desc +
* control) per declared setting. Switches render the custom switch; text and
* number rows render a free-form / numeric input committed on blur/Enter
* (clamped to the declared min/max). Extracted so the rows are testable
* without opening the Modal (the Modal portal renders only while open).
*/
export function FeatureSettingsRows(props: {
toggles: readonly SidebarSettingToggle[]
prefs: SidebarPrefs
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
/** Commit one text/number row; returns the canonical value the row should
* display (clamped for numbers, the current pref when the input is
* invalid). Optional: rows with no handler keep their draft. */
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
}) {
const { toggles, prefs, onToggle } = props
const { toggles, prefs, onToggle, onCommit } = props
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
if ((toggle.type ?? 'switch') === 'switch') {
return (
<div key={toggle.key} className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<Switch
label={title}
checked={prefBool(prefs, toggle.key)}
onChange={(next) => { onToggle(toggle, next) }}
/>
</div>
)
}
const value = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
// Keyed by the committed value: a failed commit reverts prefs, the
// key changes, and the row remounts with the stored value (typing
// never changes the key, so mid-edit drafts survive re-renders).
return (
<label key={toggle.key} className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefBool(prefs, toggle.key)}
aria-label={title}
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
/>
</label>
<TypedRow
key={`${toggle.key}:${value}`}
toggle={toggle}
title={title}
value={value}
onCommit={onCommit}
/>
)
})}
</div>
)
}
/**
* One text/number row: a controlled input whose draft is local state,
* committed on blur/Enter through the parent's onCommit. The parent's
* canonical return is adopted (clamped numbers, stored value for invalid
* input); a `unit` suffix renders after the input (e.g. 'px').
*/
function TypedRow(props: {
toggle: SidebarSettingToggle
title: string
value: string
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
}) {
const { toggle, title, value, onCommit } = props
const [draft, setDraft] = useState(value)
const commit = (): void => {
const canonical = onCommit?.(toggle, draft) ?? draft
setDraft(canonical)
}
const number = toggle.type === 'number'
return (
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<span className={css.control}>
<Input
type={number ? 'number' : 'text'}
className={number ? css.typedInputNumber : css.typedInput}
value={draft}
min={toggle.min}
max={toggle.max}
step={1}
placeholder={toggle.placeholder}
aria-label={title}
onChange={event => { setDraft(event.currentTarget.value) }}
onBlur={commit}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
</span>
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service).
@@ -219,8 +319,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
setWidthDraft(String(settled.defaultWidthPercent))
}
/** Optimistically flip one boolean pref, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => {
/** Optimistically apply one pref patch, then commit (revert on failure). */
const applyPref = (patch: Record<string, unknown>): void => {
const previous = prefs
setPrefs({ ...previous, ...patch } as SidebarPrefs)
setError(null)
@@ -228,22 +328,44 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
}
const onToggle = (next: boolean): void => {
togglePref({ openByDefault: next })
applyPref({ openByDefault: next })
}
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
applyPref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
}
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
const onToggleViewer = (id: string, next: boolean): void => {
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
applyPref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
}
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
togglePref({ [toggle.key]: next })
applyPref({ [toggle.key]: next })
}
/**
* Commit one declaratively-declared text/number row. Numbers are parsed
* and clamped to the toggle's declared min/max (an unparsable input falls
* back to the CURRENT stored value, mirroring the width row); text rows
* persist as-is (empty is meaningful, e.g. the theme-default font).
* Returns the canonical value the row should display.
*/
const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
if (toggle.type === 'number') {
const parsed = Number(raw)
const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
if (!Number.isFinite(parsed)) return fallback
let clamped = Math.round(parsed)
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
applyPref({ [toggle.key]: clamped })
return String(clamped)
}
applyPref({ [toggle.key]: raw })
return raw
}
const commitWidth = (): void => {
@@ -262,9 +384,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
/**
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the
* check badge sits at the far right, and a feature that declares related
* settings carries a gear corner button opening its settings popup.
* area is the switch (click to flips, visual state IS the state), the icon
* sits in a rounded chip, the check badge pins to the far right, and a
* feature that declares related settings carries a gear corner button
* opening its settings popup.
*/
const renderCard = (props: {
title: string
@@ -289,12 +412,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
>
<span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
<span className={css.cardIconChip}>{props.icon}</span>
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
<IconCheckOutline16 size={12} />
</span>
)}
</span>
@@ -308,7 +431,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={14} />
<IconSettingsOutline16 size={12} />
</button>
)}
</div>
@@ -317,101 +440,133 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return (
<div className={css.section}>
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
{renderCard({
title: t('settingsOpenTitle'),
desc: t('settingsOpenDesc'),
icon: <IconPanelLeftOutline16 size={16} />,
enabled: prefs.openByDefault,
onToggle,
})}
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
<p className={css.intro}>{t('settingsIntro')}</p>
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<Switch
label={t('settingsOpenTitle')}
checked={prefs.openByDefault}
onChange={onToggle}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
</span>
<Switch
label={t('settingsOpenPathTitle')}
checked={prefs.interceptOpenPath}
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
</div>
{renderCard({
title: t('settingsOpenPathTitle'),
desc: t('settingsOpenPathDesc'),
icon: <IconCodeOutline16 size={16} />,
enabled: prefs.interceptOpenPath,
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
})}
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
<div className={css.grid}>
{tabs.map(tab => (
<Fragment key={tab.id}>
{renderCard({
title: textOf(tab.title),
desc: tab.id,
icon: iconOf(tab.icon, 16),
enabled: prefs.tabsEnabled[tab.id] !== false,
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
&& (tab.settings?.toggles?.length ?? 0) > 0
? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
<div className={css.group}>
<div className={css.groupHeading}>
<span>{t('settingsTabsTitle')}</span>
<span className={css.count}>{tabs.length}</span>
</div>
<div className={css.grid}>
{tabs.map(tab => (
<Fragment key={tab.id}>
{renderCard({
title: textOf(tab.title),
desc: tab.id,
icon: iconOf(tab.icon, 16),
enabled: prefs.tabsEnabled[tab.id] !== false,
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
&& (tab.settings?.toggles?.length ?? 0) > 0
? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
</div>
</div>
{/* 文件预览: one small card per registered file viewer. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
<div className={css.grid}>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{renderCard({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
<div className={css.group}>
<div className={css.groupHeading}>
<span>{t('settingsViewersTitle')}</span>
<span className={css.count}>{viewers.length}</span>
</div>
<div className={css.grid}>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{renderCard({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
</div>
</div>
{/* The secondary settings popup: a feature's declared related settings
as native checkbox rows (Modal chrome is the app's own). Mounted
only while a feature is open — the Modal primitive runs hooks
unconditionally, so a closed-but-mounted Modal would break SSR
(and the renderToString spec) under the test dual-react split. */}
as title/desc + switch rows in a wider-than-default Modal with a
Done footer (Modal chrome is the app's own). Mounted only while a
feature is open — the Modal primitive runs hooks unconditionally,
so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split. */}
{settingsFor !== null && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })}
closeLabel={t('close')}
className={css.popupDialog}
footer={(
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
{t('settingsDone')}
</button>
)}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
/>
</Modal>
)}
+15 -11
View File
@@ -311,8 +311,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// ("squeezes the agent output area") — it starts at the app sidebar's
// right edge and ends at the details column's left edge (the details
// column sits between the center and the right panel). Measured directly
// from the AppFrame's center column DOM (children[1], the layout.css
// nth-child(2) column) so the bottom panel tracks the column's real
// from the AppFrame's center column DOM (the parent of the
// [data-slot="conversation"] wrapper — layout.css's center column) so the
// bottom panel tracks the column's real
// horizontal edges — including the animated margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured).
@@ -340,17 +341,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
useEffect(() => {
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column (children[1] of the frame div —
// layout.css's nth-child(2)). The shell swaps the boot page for the
// AppFrame only AFTER boot settles, so the first query may miss it.
// Never give up: watch #root's children (the swap mutates them) and
// re-run this locator — querying once and bailing would strand the
// panel at the zero-size fallback forever (observed: a 1px sliver at
// the viewport's left edge).
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's children (the swap
// mutates them) and re-run this locator — querying once and bailing
// would strand the panel at the zero-size fallback forever (observed:
// a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const frame = document.querySelector('#root > div')
const col = frame?.children[1] as HTMLElement | undefined
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
if (col === undefined) {
if (centerColRef.current !== null) {
centerColRef.current = null
+48 -4
View File
@@ -24,9 +24,11 @@ import { Terminal, type ITheme } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import { openWhenSized } from './open-when-sized.ts'
import type { SessionScope } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
@@ -82,10 +84,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
useEffect(() => {
const host = hostRef.current
if (host === null) return
// The custom font prefs (side card settings, terminal card) resolve at
// mount; store changes re-apply them live below.
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: font.fontSize,
fontFamily: font.fontFamily,
allowTransparency: true,
convertEol: false,
scrollback: 4000,
@@ -93,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
@@ -185,11 +188,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
observer.observe(host)
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
// on any store change re-resolve and diff the two options, re-fitting
// when they moved (the grid dimensions may change with the font). The
// subscribe fires on every store change (tabs, panels…), so the diff is
// what keeps this cheap.
const fontSub = store.subscribe(() => {
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
term.options.fontFamily = next.fontFamily
term.options.fontSize = next.fontSize
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
}
})
// The terminal must not be opened in a zero-size container: xterm's
// renderer creation fails there and the next Viewport refresh crashes
// reading `.dimensions` off the undefined renderer (blank terminal on
// WKWebView when the bottom panel's expand slide leaves the host at
// height 0; any display:none-hidden ancestor does the same). Defer
// open+fit until the host has a real size — writes arriving meanwhile
// are buffered by xterm's WriteBuffer and render once open, and
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
// the deferred path where the socket may already be open with the
// default 80x24 dims.
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
connect()
return () => {
closed = true
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
fontSub()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
+9 -1
View File
@@ -329,7 +329,15 @@ export function TextEditor(props: FileViewerProps) {
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
>
<MarkdownText text={draft ?? content ?? ''} />
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. */}
<MarkdownText
text={draft ?? content ?? ''}
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
/>
</div>
)}
{html && mode === 'preview' && (
+20 -4
View File
@@ -19,6 +19,7 @@ import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
@@ -137,10 +138,11 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch and the
// bottom-panel first-expansion auto-terminal switch render under this
// row in the Side card settings page (the Terminal page's own related
// settings; the host gates the toolset on the tools one independently).
// Declarative settings: the model-facing terminal tools switch, the
// bottom-panel first-expansion auto-terminal switch, and the custom
// font family/size rows render under this card in the Side card
// settings page (the host gates the toolset on the tools one
// independently; the font rows apply live to every terminal).
settings: {
toggles: [{
key: 'agentTerminalTools',
@@ -150,6 +152,20 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}, {
key: 'terminalFontFamily',
type: 'text',
title: () => t('settingsFontFamilyTitle'),
desc: () => t('settingsFontFamilyDesc'),
placeholder: t('settingsFontFamilyPlaceholder'),
}, {
key: 'terminalFontSize',
type: 'number',
title: () => t('settingsFontSizeTitle'),
desc: () => t('settingsFontSizeDesc'),
min: TERMINAL_FONT_SIZE_MIN,
max: TERMINAL_FONT_SIZE_MAX,
unit: 'px',
}],
},
createTab: (state) => {
+16 -3
View File
@@ -57,9 +57,22 @@ export function SidebarProducedFiles(props: {
)
}
/** Register the turn-tail interception (returns the disposer). */
/**
* Register the turn-tail interception (returns the disposer).
*
* The slot is a CHILD slot the host's ui-conversation declares in its
* `conversation.chat.node` children table (kind: chain, scope: session).
* Registering it directly races the declaration — the ui-slots core's
* load-time validation throws "not declared (a parent entry's children
* table must declare it)" when the parent entry is not on the ledger yet.
* slots.inject waits for the declaration: the callback runs synchronously
* when the slot is already declared, otherwise it runs inside the declaring
* register() call once the declaration commits; declaration collapse
* disposes the entry and a later declaration re-registers it. This mirrors
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
*/
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
@@ -73,7 +86,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles)
}, SidebarProducedFiles))
}
/**
+19 -14
View File
@@ -6,11 +6,13 @@
* conversation output and the input bar, like a VSCode sidebar.
*
* The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. The AppFrame's grid items are
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so
* the vertical push lands on the center column alone. The fixed panels
* cover the vacated strips, which looks seamless because #root's background
* is the theme base.
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
* [data-slot] containers, so the AppFrame grid lives one level deeper:
* #root > div[data-slot="root"] > div (the frame). Its grid items are
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
* vertical push lands on the center column alone. The fixed panels cover
* the vacated strips, which looks seamless because #root's background is
* the theme base.
*
* The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel
@@ -23,10 +25,10 @@
}
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid
item shrinks by its margins, so the conversation content (output + input
bar) lifts without touching the sidebars. */
#root > div > div:nth-child(2) {
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
column. A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root > div[data-slot="root"] > div > div:nth-child(2) {
margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
@@ -35,20 +37,23 @@
corner. Push the DSH session header's right padding out so its right-aligned
utilities (the "Session log" download capsule) yield the corner instead of
hiding under the cluster. The header default right-pads 28px; the 2-button
cluster spans right 10→70px, so 78px clears it with an 8px gap. The
Sidebar shell toggles the body attribute with the panel open state. */
body[data-dsh-sidebar-collapsed] #root > div > div:nth-child(2) header {
cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
the header's slot host wrapper ([data-slot="conversation.session.header"])
rather than a positional path: DSH 0.1.x nests the header several levels
under the center column. The Sidebar shell toggles the body attribute with
the panel open state. */
body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > header {
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
#root,
#root > div > div:nth-child(2) {
#root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none;
}
}
+18
View File
@@ -106,6 +106,9 @@ export const zh = {
copyAbsolute: '复制绝对地址',
download: '下载',
settingsNav: '侧边卡片',
settingsIntro: '管理侧边卡片的显示内容与默认行为',
settingsPopupDesc: '为「{feature}」配置相关选项',
settingsDone: '完成',
settingsOpenTitle: '新会话默认打开',
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
settingsWidthTitle: '默认宽度占比',
@@ -133,6 +136,12 @@ export const zh = {
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsFontFamilyTitle: '终端字体',
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
settingsFontSizeTitle: '终端字号',
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
@@ -307,6 +316,9 @@ export const en: Record<keyof typeof zh, string> = {
copyAbsolute: 'Copy absolute path',
download: 'Download',
settingsNav: 'Side card',
settingsIntro: 'Manage what the side card shows and how it behaves',
settingsPopupDesc: 'Configure related options for {feature}',
settingsDone: 'Done',
settingsOpenTitle: 'Open by default for new conversations',
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
settingsWidthTitle: 'Default width share',
@@ -334,6 +346,12 @@ export const en: Record<keyof typeof zh, string> = {
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
settingsFontFamilyTitle: 'Terminal font family',
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
settingsFontSizeTitle: 'Terminal font size',
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
+43
View File
@@ -0,0 +1,43 @@
/**
* Deferred one-shot open for hosts that may not have a real size yet.
*
* xterm's `Terminal.open()` must not run in a zero-size container: the
* renderer creation fails there (the DomRenderer is built from the host's
* dimensions), leaving the render service's renderer `undefined`, and the
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
* hosts (WKWebView) reliably report zero while the bottom panel's expand
* slide is in flight; any `display:none`-hidden ancestor does the same.
*
* The caller's `open` callback (open + fit + resize) is invoked exactly
* once, on the first frame where the host reports a real size. While the
* host stays zero-sized the polling continues every frame; it stops when
* the host leaves the document (`isConnected`), so a pending open never
* fires after unmount. The returned cancel function drops a pending frame
* immediately (idempotent).
*
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
*/
export function openWhenSized(
host: HTMLElement,
open: () => void,
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
caf: (id: number) => void = cancelAnimationFrame,
): () => void {
let frame: number | null = null
const step = (): void => {
frame = null
if (!host.isConnected) return
if (host.clientWidth > 0 && host.clientHeight > 0) {
open()
return
}
frame = raf(step)
}
frame = raf(step)
return () => {
if (frame !== null) {
caf(frame)
frame = null
}
}
}
+8 -1
View File
@@ -10,12 +10,13 @@
*/
import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs,
} from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */
@@ -49,6 +50,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
terminalFontFamily: typeof record.terminalFontFamily === 'string'
? record.terminalFontFamily
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
? clampTerminalFontSize(record.terminalFontSize)
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
+22 -5
View File
@@ -28,9 +28,15 @@ import {
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested
* switch row in the Side card settings page (e.g. the Subagent page's
* "auto-open when a subagent appears"). */
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
/** One declarative setting of a tab/viewer, rendered as a nested row in the
* Side card settings page (e.g. the Subagent page's "auto-open when a
* subagent appears" switch, or the terminal's custom font rows). `type`
* selects the control: 'switch' (default) renders the custom switch,
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
* input clamped to `min`/`max`. */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
@@ -38,15 +44,26 @@ export interface SidebarSettingToggle {
title: string | (() => string)
/** Row description (i18n friendly). */
desc?: string | (() => string)
/** Row control type; defaults to 'switch' (backward compatible). */
type?: SidebarSettingToggleType
/** Lower bound for `type: 'number'` rows (clamped on commit). */
min?: number
/** Upper bound for `type: 'number'` rows (clamped on commit). */
max?: number
/** Input placeholder for `type: 'text'` rows. */
placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string
}
/** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration {
/**
* Extra boolean toggles rendered under the feature's own row in the
* Extra settings rows rendered under the feature's own row in the
* settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
* by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
* configured under the terminal card's secondary settings) turned into the
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
* chain and clamping are unit-testable without mounting a terminal.
*/
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
/** The built-in fallback stack when neither the user nor the theme sets one. */
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
/**
* Resolve the xterm font options for the given prefs.
* @param prefs - the current side card preferences.
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
* token value, read live by the caller); undefined when the token is absent.
* @returns the `fontFamily` / `fontSize` xterm options.
*/
export function resolveTerminalFont(
prefs: SidebarPrefs,
themeFontFamily: string | undefined,
): { fontFamily: string; fontSize: number } {
const custom = prefs.terminalFontFamily.trim()
return {
fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY),
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
}
}
+8
View File
@@ -9,6 +9,9 @@ import z from 'schemastery'
import {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -18,6 +21,9 @@ import {
export {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -82,6 +88,8 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
interceptOpenPath: z.boolean().default(true),
htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false),
+14
View File
@@ -428,4 +428,18 @@ declare module 'cordis' {
}
}
/**
* Dual cordis-scope augmentation: DSH's runtime (and the public packages)
* resolve against the vendored `@deepseek-ai/cordis` scope, which DSH's own
* packages already augment with `effect`/`on`/service members. This side only
* adds the sidebar service member so consumers importing `Context` from
* `@deepseek-ai/cordis` (instead of the public `cordis` above) still see
* `ctx.betterSidebar`.
*/
declare module '@deepseek-ai/cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
export type { Context }
+24
View File
@@ -33,6 +33,18 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Custom terminal font-family stack (a CSS font-family value, e.g.
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
* monospace font (`--ds-font-family-code`). Applied live to every
* terminal tab; configured under the terminal card's secondary settings.
*/
terminalFontFamily: string
/**
* Custom terminal font size in px (9–32). Applied live to every terminal
* tab; configured under the terminal card's secondary settings.
*/
terminalFontSize: number
/**
* Whether expanding the bottom panel for the FIRST time in a session tries
* to open a fresh terminal tab there (the terminal quota/type still gates
@@ -104,6 +116,11 @@ export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
@@ -112,6 +129,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -125,3 +144,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
export function clampWidthPercent(value: number): number {
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
}
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
export function clampTerminalFontSize(value: number): number {
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
}
+14 -2
View File
@@ -47,10 +47,22 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
})
it('the terminal tab declares the model terminal-tools related setting', () => {
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal'])
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize'])
// The font rows are text/number inputs (not switches), with the size
// row bounded by the shared 9–32 contract.
expect(toggles[2]?.type).toBe('text')
expect(toggles[2]?.title).toBeDefined()
expect(toggles[2]?.placeholder).toBeDefined()
expect(toggles[3]?.type).toBe('number')
expect(toggles[3]?.min).toBe(9)
expect(toggles[3]?.max).toBe(32)
expect(toggles[3]?.unit).toBe('px')
// The first two rows stay plain boolean switches.
expect(toggles[0]?.type ?? 'switch').toBe('switch')
expect(toggles[1]?.type ?? 'switch').toBe('switch')
})
it('the browser tab declares its sandbox and link-takeover related settings', () => {
+73
View File
@@ -0,0 +1,73 @@
/**
* Markdown preview copy-label spec: the fence copy buttons ("复制" / "Copy")
* must follow the DSH locale service through `codeLabels` — the DSH
* MarkdownText/CodeBlock are cordis-free and fall back to HARDCODED Chinese
* when the caller omits the labels, so TextEditor must pass its own
* dictionary copy (`t('copy')` / `t('copied')`), re-evaluated per render.
* Rendered with renderToString (the initial preview mode never mounts
* CodeMirror, so no DOM/effects are needed).
*/
import { describe, expect, it, afterEach } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import './browser-globals.ts'
import { TextEditor } from '../src/client/TextEditor.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { attachLocale } from '../src/client/locales.ts'
import type { FileViewerProps } from '../src/client/service.ts'
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
active: string = 'zh'
getSnapshot(): { active: string } {
return { active: this.active }
}
subscribe(_fn: () => void): () => void {
return () => {}
}
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
return () => {}
}
}
const CTX = {} as Parameters<typeof TextEditor>[0]['ctx']
/** A markdown source with one fenced code block (the copy-button surface). */
const MD_WITH_FENCE = '```ts\nconst a = 1\n```'
function viewerProps(overrides: Partial<FileViewerProps> = {}): FileViewerProps {
return {
ctx: CTX,
store: createSidebarStore(),
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a/README.md',
title: 'README.md',
viewerId: 'markdown',
content: MD_WITH_FENCE,
...overrides,
}
}
afterEach(() => {
attachLocale(undefined)
})
describe('markdown preview code-block copy labels (DSH i18n following)', () => {
it('renders the fence copy button with the zh dictionary label by default', () => {
const locale = new FakeLocale()
locale.active = 'zh'
attachLocale(locale)
const html = renderToString(createElement(TextEditor, viewerProps()))
expect(html).toContain('复制')
expect(html).not.toContain('Copy')
})
it('follows the attached locale service live: en renders "Copy" instead of the zh label', () => {
const locale = new FakeLocale()
locale.active = 'en'
attachLocale(locale)
const html = renderToString(createElement(TextEditor, viewerProps()))
expect(html).toContain('Copy')
expect(html).not.toContain('复制')
})
})
+138
View File
@@ -0,0 +1,138 @@
/**
* openWhenSized tests: the deferred one-shot open guard for hosts that may
* not have a real size yet (xterm crashes when opened in a zero-size
* container — the WKWebView bottom-panel blank-terminal bug, issue #25).
* The polling must open exactly once, only once the host reports a real
* size, stop when the host leaves the document, and cancel cleanly.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { openWhenSized } from '../src/client/open-when-sized.ts'
/** A manually-stepped raf/caf pair so the polling is deterministic. */
function makeScheduler(): {
raf: (cb: FrameRequestCallback) => number
caf: (id: number) => void
tick: () => void
pending: () => number
} {
let nextId = 0
const frames = new Map<number, FrameRequestCallback>()
return {
raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id },
caf: (id) => { frames.delete(id) },
tick: () => {
const cbs = [...frames.values()]
frames.clear()
for (const cb of cbs) cb(0)
},
pending: () => frames.size,
}
}
/** A host whose reported size can be changed between ticks. */
function makeHost(width: number, height: number): {
el: HTMLElement
setSize: (w: number, h: number) => void
} {
const el = document.createElement('div')
document.body.appendChild(el)
const setSize = (w: number, h: number): void => {
Object.defineProperty(el, 'clientWidth', { value: w, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: h, configurable: true })
}
setSize(width, height)
return { el, setSize }
}
describe('openWhenSized', () => {
it('opens on the first tick when the host already has a size, then stops', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(320, 200)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
expect(opened).toBe(1)
expect(pending()).toBe(0)
tick()
expect(opened).toBe(1)
cancel()
})
it('defers while the host is zero-sized and opens exactly once once sized', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
tick()
tick()
expect(opened).toBe(0)
expect(pending()).toBe(1)
host.setSize(320, 200)
tick()
expect(opened).toBe(1)
expect(pending()).toBe(0)
tick()
expect(opened).toBe(1)
cancel()
})
it('opens when only one dimension was missing', () => {
const { raf, caf, tick } = makeScheduler()
const host = makeHost(320, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
expect(opened).toBe(0)
host.setSize(320, 120)
tick()
expect(opened).toBe(1)
cancel()
})
it('cancels a pending open and leaves no scheduled frames', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
cancel()
expect(pending()).toBe(0)
host.setSize(320, 200)
tick()
expect(opened).toBe(0)
// Idempotent: cancelling again is a no-op.
cancel()
})
it('stops polling when the host leaves the document', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
host.el.remove()
tick()
expect(opened).toBe(0)
expect(pending()).toBe(0)
// Even if the host somehow got a size after detaching, nothing opens.
host.setSize(320, 200)
tick()
expect(opened).toBe(0)
cancel()
})
it('does not swallow exceptions from open (the caller owns error handling)', () => {
const { raf, caf, tick } = makeScheduler()
const host = makeHost(320, 200)
let calls = 0
const cancel = openWhenSized(host.el, () => {
calls += 1
throw new Error('boom')
}, raf, caf)
expect(() => tick()).toThrow('boom')
expect(calls).toBe(1)
cancel()
})
})
+5 -1
View File
@@ -50,6 +50,10 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
// The terminal font customizations default to the theme (empty family)
// and 13px.
expect(resolved.terminalFontFamily).toBe('')
expect(resolved.terminalFontSize).toBe(13)
// The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({})
@@ -59,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
+175
View File
@@ -0,0 +1,175 @@
/**
* Interactive tests for the settings popup's text/number rows: the draft is
* local state committed on blur through the parent's onCommit; the parent's
* canonical return is adopted (clamped numbers, stored value for invalid
* input), and the row remounts when the committed pref value changes (a
* failed commit reverts prefs → the stored value reappears).
*
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
* side-card-section.spec.tsx; this file exercises the event paths).
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import type { SidebarSettingToggle } from '../src/client/service.ts'
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** Render the rows into a detached container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
return {
container,
rerender: (next) => { act(() => { root.render(next) }) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into an input and commit it via blur (React 18: input event +
* focusout). The native setter bypasses React's value tracker so the
* change is actually seen. */
function typeAndBlur(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})
}
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
describe('FeatureSettingsRows typed rows (interactive)', () => {
it('commits the raw text on blur and adopts the canonical return', () => {
const commits: Array<[string, string]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onCommit: (t, raw) => {
commits.push([t.key, raw])
return raw
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'Monaco')
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
// The canonical return is adopted into the draft.
expect(input.value).toBe('Monaco')
unmount()
})
it('clamps numbers into the declared bounds on commit', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
onCommit: (t, raw) => {
const parsed = Number(raw)
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '40')
expect(commits).toEqual([['terminalFontSize', 32]])
expect(input.value).toBe('32')
unmount()
})
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
// The parent mirrors the real handler: an emptied number parses to 0
// and clamps into the bounds (a browser number input never holds a
// non-numeric string — the draft can only be empty or numeric).
onCommit: (t, raw) => {
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '')
expect(commits).toEqual([['terminalFontSize', 9]])
expect(input.value).toBe('9')
unmount()
})
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
// The parent mirrors the real handler: the optimistic commit adopts the
// typed value, and a FAILED write reverts prefs to the stored one.
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'New')
// Optimistic commit: the draft adopts the typed value.
expect(input.value).toBe('New')
// The optimistic pref lands (parent state): the key changes, the row
// remounts with the same value — no visible reset while editing.
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('New')
// The write fails: prefs revert to the stored value and the row
// remounts with it (the stale draft is gone).
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('Old')
unmount()
})
})
+71 -10
View File
@@ -5,9 +5,11 @@
* copy. The toggles are CARDS in a responsive grid: the card's main area is
* the switch, the visual state IS the state (highlighted = enabled),
* announced via `aria-pressed`, and the check badge sits at the far right.
* Features that declare related settings carry a gear corner button whose
* popup rows (native checkboxes) are tested through the extracted
* FeatureSettingsRows component (the Modal portal renders only while open).
* The general rows follow the DSH settings-row recipe with custom SWITCHES
* (real checkboxes driving a styled track). Features that declare related
* settings carry a gear corner button whose popup rows (switch controls)
* are tested through the extracted FeatureSettingsRows component (the Modal
* portal renders only while open).
*
* Rendered with renderToString (mount effects — the settings RPC sync — do
* not run in SSR; the initial store prefs are the render input).
@@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
// image viewer are all enabled → 5 cards pressed, none pressed=false.
// Default prefs: openByDefault + interceptOpenPath switches checked, and
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(5)
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, checked).
expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html).not.toContain('Auto-open Subagents')
})
it('renders the section intro and group headings with inventory counts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('Manage what the side card shows and how it behaves')
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
expect(html).toContain('>Sidebar content</span><span')
expect(html).toContain('>File viewers</span><span')
expect(html).toContain('>2</span>')
expect(html).toContain('>1</span>')
})
it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
@@ -111,8 +126,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
// openByDefault + interceptOpenPath + the explorer stay pressed.
expect(pressedCount(html, 'true')).toBe(3)
// The explorer card stays pressed; the general switches stay checked.
expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(2)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -134,7 +150,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
desc: () => 'Expand on new subagent',
}]
it('renders one native checkbox row per declared toggle with its current value', () => {
it('renders one switch row per declared toggle with its current value', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs,
@@ -142,7 +158,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
}))
expect(html).toContain('Auto-open Subagents')
expect(html).toContain('Expand on new subagent')
// The checkbox reflects the prefs value (false here → unchecked).
// The row's switch is a real checkbox (aria-label = the toggle title)
// reflecting the prefs value (false here → unchecked).
expect(html).toContain('aria-label="Auto-open Subagents"')
expect(html).not.toContain('checked=""')
})
@@ -154,4 +172,47 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
}))
expect(html).toContain('checked=""')
})
it('renders a text row as an input seeded with the pref value (empty = theme default)', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
desc: () => 'CSS stack',
placeholder: '"JetBrains Mono", monospace',
}],
prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' },
onToggle: () => {},
onCommit: () => '',
}))
expect(html).toContain('Font family')
expect(html).toContain('placeholder="&quot;JetBrains Mono&quot;, monospace"')
// The input carries the pref value (no switch for text rows).
expect(html).toContain('value="&quot;JetBrains Mono&quot;, monospace"')
expect(html).not.toContain('type="checkbox"')
})
it('renders a number row with the pref value, the declared bounds and a unit suffix', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
unit: 'px',
}],
prefs: { ...prefs, terminalFontSize: 18 },
onToggle: () => {},
onCommit: () => '18',
}))
expect(html).toContain('Font size')
expect(html).toContain('type="number"')
expect(html).toContain('value="18"')
expect(html).toContain('min="9"')
expect(html).toContain('max="32"')
expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"')
})
})
+2
View File
@@ -517,6 +517,8 @@ describe('side card settings routes', () => {
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
+42
View File
@@ -0,0 +1,42 @@
/**
* Terminal font resolution tests: the custom font prefs (side card settings,
* terminal card) resolve into xterm options with a strict fallback chain —
* user family > theme code font > the built-in monospace stack, and the size
* clamped into the 9–32 contract.
*/
import { describe, expect, it } from 'vitest'
import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts'
import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts'
describe('clampTerminalFontSize', () => {
it('rounds and clamps into the 9–32 contract', () => {
expect(clampTerminalFontSize(13)).toBe(13)
expect(clampTerminalFontSize(15.6)).toBe(16)
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
describe('resolveTerminalFont', () => {
it('falls back to the theme code font when the family pref is empty', () => {
const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font')
expect(fontFamily).toBe('var-theme-font')
expect(fontSize).toBe(13)
})
it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => {
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
// A whitespace-only pref counts as empty (theme default).
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
.toBe(DEFAULT_TERMINAL_FONT_FAMILY)
})
it('prefers the custom family verbatim and clamps the size', () => {
const { fontFamily, fontSize } = resolveTerminalFont(
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 },
'var-theme-font',
)
expect(fontFamily).toBe('"JetBrains Mono", monospace')
expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
+181
View File
@@ -0,0 +1,181 @@
/**
* Turn-tail interception registration spec (issue #15): `registerTurnTailInterception`
* must go through `ctx.slots.inject` — the slot is a CHILD slot the host's
* ui-conversation declares in its `conversation.chat.node` children table, so a
* direct `slots.register` races the declaration and the ui-slots core throws
* "not declared (a parent entry's children table must declare it)".
*
* The fake `slots` mirrors SlotRegistry.inject's semantics: run the callback
* synchronously when the slot is already declared; otherwise wait and run it
* when the declaration commits; the returned disposer cancels a pending wait
* and disposes any active registration; the register disposer is idempotent.
*/
import { describe, expect, it, vi } from 'vitest'
import { createSidebarStore } from '../src/client/state.ts'
import { registerTurnTailInterception } from '../src/client/intercept.tsx'
import type { Context } from '../src/context-types.ts'
interface RegisteredSlot {
options: Record<string, unknown>
component: unknown
}
/**
* A structural fake of the client slots service. `declared` selects the
* timing: already-on-ledger (callback runs synchronously) vs. pending
* (callback runs on `declare()`, unless the controller was disposed first).
*/
const fakeSlots = (declared: boolean) => {
const registered: RegisteredSlot[] = []
const disposals: number[] = []
const pendings: Array<() => void> = []
return {
registered,
disposals,
pendings,
slots: {
register: (options: Record<string, unknown>, component: unknown) => {
registered.push({ options, component })
return () => { disposals.push(1) }
},
inject: (key: string, callback: () => () => void) => {
if (key !== 'conversation.chat.turnTail') {
throw new Error(`unexpected injected key "${key}"`)
}
let active: (() => void) | undefined
let stopped = false
const run = (): void => {
if (stopped || active !== undefined) return
active = callback()
}
if (declared) run()
else pendings.push(run)
return () => {
stopped = true
active?.()
active = undefined
}
},
},
}
}
/** A produced-files owner currency: one closing assistant seq + its nodes. */
const producedOwner = (paths: string[]): unknown => ({
nodes: [
{ kind: 'assistant', seq: 1, turn: 1 },
...paths.map(path => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})),
{ kind: 'assistant', seq: 2, turn: 1 },
],
seq: 2,
})
const emptyOwner = (): unknown => ({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })
/** The minimal client-context fake the registration (and its seats) touch. */
const clientCtx = (slots: unknown): Context => ({
slots,
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { id: 's1', cwd: '/w', displayTitle: 's1' } } }) },
},
betterSidebar: { openTab: vi.fn() },
} as unknown as Context)
describe('turn-tail interception registration (issue #15)', () => {
it('registers through slots.inject and lands once the slot is already declared', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// Exactly one registration, with the takeover descriptor.
expect(fake.registered).toHaveLength(1)
const { options, component } = fake.registered[0]!
expect(options.name).toBe('conversation.chat.turnTail')
expect(options.priority).toBe(-1)
expect(options.registrant).toBe('dsh-better-sidebar')
expect(options.select).toBeTypeOf('function')
expect(options.inject).toBeTypeOf('function')
expect(component).toBeTypeOf('function')
// Disposal removes the registration; the disposer is idempotent.
restore()
expect(fake.disposals).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('waits for the host declaration instead of throwing (the pre-fix race)', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// The slot is undeclared: nothing registered yet, no error.
expect(fake.registered).toHaveLength(0)
// The host's ui-conversation commits the declaration → the entry lands.
expect(fake.pendings).toHaveLength(1)
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
// A later re-declaration does not double-register while the entry is live.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('a disposal before the declaration cancels the wait permanently', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
restore()
// The declaration arrives after the controller was disposed: ignored.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(0)
})
it('declines the takeover while the editor tab is disabled in the settings', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
const select = fake.registered[0]!.options.select as (owner: unknown) => unknown
// Enabled (default): a produced turn claims the chain; an empty one declines.
expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts'])
expect(select(emptyOwner())).toBeNull()
// Editor tab disabled: even a produced turn falls back to the default
// deliverables row (chips that cannot open must not be offered).
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
expect(select(producedOwner(['a.ts']))).toBeNull()
restore()
})
it('wires the openInSidebar seat to the sidebar editor tab', () => {
const fake = fakeSlots(true)
const ctx = clientCtx(fake.slots)
const store = createSidebarStore()
const restore = registerTurnTailInterception(ctx, store)
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
openInSidebar: (path: string) => void
}
// The seat hands the session-scoped opener to the chips row.
const seat = inject('s1')
expect(seat.openInSidebar).toBeTypeOf('function')
seat.openInSidebar('/w/src/a.ts')
expect(ctx.betterSidebar.openTab).toHaveBeenCalledWith({
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
})
restore()
})
})
+26 -4
View File
@@ -1153,6 +1153,8 @@ describe('side card preferences', () => {
autoOpenJobs: true,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -1172,6 +1174,8 @@ describe('side card preferences', () => {
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -1191,6 +1195,8 @@ describe('side card preferences', () => {
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -1225,6 +1231,22 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
})
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
// Absent → theme default (empty family) + default size.
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
// A custom family survives verbatim; a malformed one falls back.
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
.toBe('"JetBrains Mono", monospace')
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
// The size clamps into 9–32 (rounded); non-numbers fall back.
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
@@ -1242,9 +1264,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -1280,7 +1302,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1288,7 +1310,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])