diff --git a/README.md b/README.md
index f211723..2ab6547 100644
--- a/README.md
+++ b/README.md
@@ -35,43 +35,115 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
## 🚀 安装
-前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。插件已发布到 npm:**`dsh-better-sidebar@0.10.2`**(`@deepseek-ai/*` peer 依赖对齐宿主实际版本 `^0.1.0-rc.6` / `@deepseek-ai/cordis@^4.0.1`,同版本单实例)。包内声明了 `dsh.bundle.patch`(随包发布的 `cordis.patch.yml`),安装后由官方 CLI 自动挂载,不修改 DSH 源码。
+**前置**:已装好 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
-# 指定版本: curl -fsSL <同上> | bash -s 0.10.2
-# 装完自动重启 DSH: curl -fsSL <同上> | bash -s -- --restart
```
-脚本自动完成:`pnpm add` 登记依赖到 `~/.dsh/profiles/web/package.json` → 预写 `allowBuilds`(node-pty/protobufjs,规避 pnpm 11 的构建脚本拦截)→ 识别包内 `dsh.bundle.patch`,把插件自动加进 `dsh.profile.bundles` 挂载 → 幂等清理旧的手动挂载行(防双挂载)。版本默认最新(`latest`),可传参;`--dry-run` 只预览不落盘。
+**Windows(PowerShell 5.1+ / pwsh)**:
-### 手动安装(npx dsh + 命令链)
+```powershell
+irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
+```
-等价于上面脚本的一连串 bash 命令(全新 profile 首次装可能因构建脚本被拦,链里 `||` 分支会自动批准并重试一次):
+装完**重启 DSH 并硬刷新**(Cmd/Ctrl+Shift+R)即可看到侧边栏。
+
+
+指定版本 / 装完自动重启(可选)
```sh
-cd ~/.dsh/profiles/web \
- && npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar \
- || (pnpm approve-builds --all \
- && npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar)
-# 固定版本:把上面的 dsh-better-sidebar 换成 dsh-better-sidebar@0.10.2
+# 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
```
-不带 `@<版本>` 时,pnpm 解析 npm 的 `latest` 标签(最新发布版)。`dsh plugin add` 完成:`pnpm add` 登记依赖 → 识别 `dsh.bundle.patch` 自动注册到 `dsh.profile.bundles` → 下次启动自动挂载(无需手写 `cordis.patch.yml`)。
+不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
-> ⚠️ pnpm 11 的两道供应链策略,命令链无法完全规避:
-> - `strict-dep-builds` 拦截 node-pty/protobufjs 的构建脚本:首次装报 `Ignored build scripts`(包其实已装上、node-pty 预编译产物可直接用),`pnpm approve-builds --all` 一次即可。
-> - `minimumReleaseAge` 拒绝发布 <24h 的新版本:这是 pnpm 的策略、命令链无法完全规避,仅因插件刚发布不久才会触发,**24 小时后自动消失**(触发时重跑一次,pnpm 会自动补 `minimumReleaseAgeExclude`)。
+
-### 更新
+
+手动安装(逐步命令,想看清每一步)
+
+与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
+
+**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
+```
+
+
+
+
+脚本内部做了什么(技术细节)
+
+一键脚本自动完成 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 源码**。
+
+
+
+
+更新
```sh
dsh plugin --profile web add dsh-better-sidebar
```
-或重跑一次一键脚本;也可手动把 `~/.dsh/profiles/web/package.json` 版本号改新版后 `pnpm install`。随后重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)。
+或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**重启 DSH 并硬刷新**(Cmd/Ctrl+Shift+R)。
+
+
+
+
+常见问题
+
+| 现象 | 原因与解决 |
+|---|---|
+| 报 `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 命令。 |
+
+
从源码安装 / 开发(可选,替代 npm 方式)
@@ -82,12 +154,15 @@ dsh plugin --profile web add dsh-better-sidebar
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 追加挂载行(同上)
+3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
+ - insert:
+ - id: better-sidebar
+ name: 'dsh-better-sidebar'
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 重启 DSH 并硬刷新
```
-更新:`git pull && pnpm install && pnpm build` → 重启 DSH(仅 client 改动可硬刷新)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.2"` 再 `pnpm install`。
+更新:`git pull && pnpm install && pnpm build` → 重启 DSH(仅 client 改动可硬刷新)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.3"` 再 `pnpm install`。
diff --git a/README_EN.md b/README_EN.md
index ba68d07..5f43479 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -35,43 +35,115 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
## 🚀 Installation
-Prerequisites: DSH installed (`dsh web` works), Node.js ≥ 20, pnpm ≥ 10. The plugin is published to npm as **`dsh-better-sidebar@0.10.2`** (`@deepseek-ai/*` peers aligned with the host's actual versions `^0.1.0-rc.6` / `@deepseek-ai/cordis@^4.0.1`, single instance). The package declares `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the official CLI mounts it after install — the DSH source is never modified.
+**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
-### One-click install (recommended)
+**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
-# Pick a version: curl -fsSL | bash -s 0.10.2
-# Auto-restart DSH: curl -fsSL | bash -s -- --restart
```
-The script: runs `pnpm add` to register the dependency in `~/.dsh/profiles/web/package.json`, pre-writes `allowBuilds` (node-pty/protobufjs, to dodge pnpm 11's build-script block), detects the package's `dsh.bundle.patch`, auto-appends the plugin to `dsh.profile.bundles`, and idempotently cleans up the old hand-written mount line (to avoid double-mounting). Version defaults to latest (`latest`); `--dry-run` previews without writing.
+**Windows (PowerShell 5.1+ / pwsh)**:
-### Manual install (npx dsh + command chain)
+```powershell
+irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
+```
-The same steps as a chain of bash commands (a fresh profile may hit the build-script block once; the `||` branch approves and retries):
+Then **restart DSH and hard-refresh** (Cmd/Ctrl+Shift+R) to see the sidebar.
+
+
+Pin a version / auto-restart (optional)
```sh
-cd ~/.dsh/profiles/web \
- && npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar \
- || (pnpm approve-builds --all \
- && npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar)
-# Pin a version: replace dsh-better-sidebar with dsh-better-sidebar@0.10.2
+# 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
```
-Without `@`, pnpm resolves npm's `latest` tag (the newest release). `dsh plugin add` runs `pnpm add` to register the dependency, detects `dsh.bundle.patch`, auto-appends the plugin to `dsh.profile.bundles`, and the next boot mounts it — no hand-written `cordis.patch.yml` mount line.
+Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
-> ⚠️ pnpm 11's two supply-chain policies, which no command chain can fully dodge:
-> - `strict-dep-builds` blocks node-pty/protobufjs build scripts: the first install reports `Ignored build scripts` (the package is already installed and node-pty's prebuilds work as-is); run `pnpm approve-builds --all` once.
-> - `minimumReleaseAge` rejects releases younger than 24h: this is a pnpm policy that no command chain can fully avoid — it only triggers because the plugin was just published, and **it disappears on its own after 24 hours** (if it fires, just re-run once; pnpm auto-appends a `minimumReleaseAgeExclude` entry).
+
-### Updating
+
+Manual install (step by step)
+
+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
+```
+
+
+
+
+What the script does (technical details)
+
+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.
+
+
+
+
+Updating
```sh
dsh plugin --profile web add dsh-better-sidebar
```
-or re-run the one-click script; or bump the version range in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.2"`) and run `pnpm install`. Then restart DSH and hard-refresh (Cmd/Ctrl+Shift+R).
+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 restart DSH and hard-refresh (Cmd/Ctrl+Shift+R).
+
+
+
+
+Troubleshooting
+
+| 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. |
+
+
Install from source / develop (optional — alternative to the npm flow)
@@ -82,12 +154,15 @@ To debug local changes or track the dev branch, point the dependency at a local
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:"
-3. Append the mount line to ~/.dsh/profiles/web/cordis.patch.yml (same as above)
+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` → restart DSH (client-only changes can just hard-refresh). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.2"` and re-run `pnpm install`.
+Update: `git pull && pnpm install && pnpm build` → restart DSH (client-only changes can just hard-refresh). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.3"` and re-run `pnpm install`.
diff --git a/dsh.plugin.json b/dsh.plugin.json
index 092de97..8eb53df 100644
--- a/dsh.plugin.json
+++ b/dsh.plugin.json
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
- "version": "0.10.2",
+ "version": "0.10.3",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
diff --git a/package.json b/package.json
index 1b99b02..7e76dca 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
- "version": "0.10.2",
+ "version": "0.10.3",
"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.",
"type": "module",
"repository": {
@@ -66,6 +66,7 @@
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
+ "scripts/install.ps1",
"cordis.patch.yml",
"README.md",
"README_EN.md",
diff --git a/scripts/install.ps1 b/scripts/install.ps1
new file mode 100644
index 0000000..ab083c0
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,242 @@
+# =============================================================================
+# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
+#
+# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
+# dsh plugin --profile web add dsh-better-sidebar@
+#
+# 包内声明了 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(会短暂断开当前页面会话)'
+ }
+}
diff --git a/scripts/install.sh b/scripts/install.sh
index ec27d9a..979c7df 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# =============================================================================
-# dsh-better-sidebar 一键安装脚本(官方 CLI 方式)
+# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@
@@ -13,14 +13,15 @@
# 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run]
#
-# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
-# 示例:0.10.2、^0.10.2、~0.10.2、latest
+# 版本 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
+# 环境(均可省略,脚本会自动探测):
+# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
#
@@ -28,6 +29,8 @@
# - 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 依赖
@@ -35,7 +38,25 @@
# =============================================================================
set -euo pipefail
-DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
+# 帮助请求优先处理
+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"
@@ -50,7 +71,8 @@ for arg in "$@"; do
case "$arg" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
- -*) echo "未知参数: $arg" >&2; exit 2 ;;
+ -h|--help) : ;; # 已在上面处理
+ -*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$arg" ;;
esac
done
@@ -59,15 +81,25 @@ 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")
+# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() {
local given="${1:-latest}"
case "$given" in
latest)
- local v
- v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" \
- || die "无法从 $REGISTRY 解析 $PKG 的最新版本(请检查网络/登录)。可显式传版本号:bash scripts/install.sh 0.10.2"
- printf '%s' "$v"
+ 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
@@ -77,11 +109,16 @@ resolve_spec() {
dsh_cli() {
if command -v "$DSH_CMD" >/dev/null 2>&1; then
printf '%s' "$DSH_CMD"
- else
+ 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)"
@@ -90,35 +127,52 @@ 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)"
+ 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:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
- if [ "$RESTART" = true ]; then say "[dry-run] 步骤 4:pm2 restart dsh-web"; else say "[dry-run] 步骤 4:提示用户手动重启 DSH"; fi
+ 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:预写 allowBuilds(幂等),保证 pnpm 不为 node-pty/protobufjs 拦截构建
-if ! grep -qE '^\s*node-pty: true\s*$' "$WS_YML"; then
- # 清除 pnpm 自动写入的占位值("set this to true or false")后写入 true
- node -e '
- const fs = require("fs");
- const p = process.argv[1];
- let t = fs.readFileSync(p, "utf8");
- t = t.replace(/node-pty:.*/g, "node-pty: true");
- t = t.replace(/protobufjs:.*/g, "protobufjs: true");
- if (!t.includes("allowBuilds:")) t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
- fs.writeFileSync(p, t);
- ' "$WS_YML"
- say "已确保 $WS_YML 的 allowBuilds(node-pty/protobufjs: true)"
-else
- say "allowBuilds 已就绪,跳过"
-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 失败。可能原因:minimumReleaseAge(发布 <24h,pnpm 通常自动写入排除并重试)"
- warn "或 allowBuilds 未生效。可手动重试:cd $PROFILE_DIR && pnpm install"
+ warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
+ warn " - 网络/登录问题:npm registry 不可达或需要登录。"
+ warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
exit 1
fi
@@ -133,24 +187,46 @@ if ! node -e '
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
fi
-say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
+say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
-if grep -qE '^\s*- id: better-sidebar\b' "$PATCH_YML"; then
- node -e '
- const fs = require("fs");
- const p = process.argv[1];
- let t = fs.readFileSync(p, "utf8");
- // 移除 better-sidebar 的 insert 块(含其前的注释行)
- t = t.replace(/(?:^[ \t]*#[^\n]*\n)*[ \t]*- insert:\n[ \t]+- id: better-sidebar\n[ \t]+name: '"'"'dsh-better-sidebar'"'"'\n?/g, "");
- // 清理可能留下的空行堆积
- t = t.replace(/\n{3,}/g, "\n\n");
- fs.writeFileSync(p, t);
- ' "$PATCH_YML"
- say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
-else
- say "无旧手动挂载行,跳过"
-fi
+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"
diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx
index 867fc48..e8f3b70 100644
--- a/src/client/intercept.tsx
+++ b/src/client/intercept.tsx
@@ -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))
}
/**
diff --git a/tests/turn-tail-intercept.spec.ts b/tests/turn-tail-intercept.spec.ts
new file mode 100644
index 0000000..8504fa1
--- /dev/null
+++ b/tests/turn-tail-intercept.spec.ts
@@ -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
+ 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, 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()
+ })
+})