Bundle
dsh-plugin-type-to-compose
Type or paste anywhere in the DSH window and it goes straight into the current conversation's composer — no click needed.
- Source
- daoyu1993-lab
- License
- MIT
- Updated
- Updated 8 days ago
Readme
# dsh-plugin-type-to-compose
**在 DSH 窗口里任意位置直接打字或粘贴,内容会落进当前对话的输入框 —— 不用先点一下输入框。**
[English](README.en.md) · MIT · 零依赖 · 零构建
典型场景:点开一条历史消息、在侧边栏点了个会话、或者用鼠标选完文字之后,
直接敲键盘 / 按 ⌘V 就能继续写;不用把鼠标移回底部输入框再点一下。
| 你正在哪里 | 按键 | 结果 |
|---|---|---|
| 消息区 / 侧边栏按钮 / 页面空白(焦点在 body) | `a` `1` `/` `中`… | ✅ 焦点移交输入框,字符进入输入框 |
| 消息区 / 侧边栏按钮 / 页面空白 | **⌘V / Ctrl+V / Shift+Insert** | ✅ 焦点移交输入框,粘贴内容进入输入框(图片/文件也走官方粘贴逻辑) |
| 输入框本身(已经聚焦) | 任意 | ✅ 照常输入,不重复、不抢焦点 |
| 搜索框 / 自定义回答框等其它输入控件 | 任意 | ⛔ 不动,字符/粘贴留在原处 |
| 终端(`.xterm`) | 任意 | ⛔ 不动 |
| 已打开的弹窗 / 菜单(`role=dialog[aria-modal]`、`role=menu`) | 任意 | ⛔ 不动,键盘归弹窗 |
| 输入框被禁用 / 未就绪(Hero 态、被阻塞的会话) | 任意 | ⛔ 不动(没有可写的地方) |
| — | 其它 ⌘/Ctrl/Alt 组合键、Tab、Enter、Esc、方向键、退格、空格 | ⛔ 不动(见下) |
---
## 安装
```bash
# 本地目录(本仓库 clone 下来之后)
dsh plugin --profile desktop add "link:$PWD"
# 或直接从 GitHub
dsh plugin --profile desktop add "github:daoyu1993-lab/dsh-plugin-type-to-compose"
```
它会把这个包加进 profile 的 `dependencies` 与 `dsh.profile.bundles`,
并在 `node_modules/` 下建一个指向本目录的**软链接** —— 所以在仓库里改 `client.js`
会被 dsh-hmr 热更新直接读到。应用运行中也能安装。
> **装完刷新页面(⌘R)即可生效。**
> `dsh-hmr` 会监听 profile 的 `package.json`,新增 bundle 会被热同步。
作者机器上的 profile(`~/.dsh/profiles/desktop/package.json`)就是这样装的:
```jsonc
"dependencies": {
"dsh-plugin-type-to-compose": "link:/Users/wangdaoyu/Documents/deepseek-harness/DSH UI Lab/dsh-plugin-type-to-compose"
},
"dsh": { "profile": { "bundles": [ ..., "dsh-plugin-type-to-compose" ] } }
```
### 卸载 / 回滚
```bash
dsh plugin --profile desktop remove dsh-plugin-type-to-compose # 需完全退出 App
```
或直接编辑 `~/.dsh/profiles/desktop/package.json`:从 `dependencies` 与
`dsh.profile.bundles` 里各删掉一行,重启应用。插件本身不改任何官方文件,
删掉之后界面立即回到原样。
---
## 实现方式:一个 document 级监听(keydown + paste)
DSH 的输入框是 Lexical 托管的 `contenteditable`,官方在它身上挂了稳定的锚点
`[data-composer-input]`(`@deepseek-ai/dsh-client-ui-conversation` 里的
`ComposerContentEditable`),旁边还有 `[data-input-scroll]` / `[data-composer-placeholder]`。
本插件只做一件事:
```
keydown(用户开始打字)
→ 判断这一下是不是「文本输入意图」、目标是不是别人家的控件
→ 把焦点移到 [data-composer-input]
→ 浏览器自己的默认动作把字符插进这个刚获得焦点的元素
```
没有回放、没有合成输入事件、没有任何槽位注册,也没有一行 CSS —— 最坏情况是
「条件判断失效 → 不接管」,永远不会把输入框弄坏。
### 为什么监听写在**冒泡**阶段
捕获阶段会在所有其它处理器之前跑,可能抢走组件已经认领的键。冒泡阶段里
React(挂在 `#root`)与页面上的处理器都已跑完,于是 `event.defaultPrevented`
是个真实信号:**任何被 DSH 自己消费掉的键我们都不碰**。
### 为什么是「只聚焦」而不是「preventDefault + 手动插入」
两条路线都实现过并实测(`tools/type-to-compose-probe.mjs`,真实 Chrome + CDP 真实按键):
| 变体 | 打字后输入框内容 | 焦点 | 备注 |
|---|---|---|---|
| 不接管(对照组) | `""` | BODY | 点开消息后打字确实什么都不会发生 |
| **只聚焦**(本插件采用) | `"abc"` | composer | 依赖浏览器默认动作插进「刚获得焦点」的元素,零副作用 |
| preventDefault + `execCommand('insertText')` | `"abc"` | composer | 结果相同,但多用了已废弃 API、且放弃原生插入路径 |
两者结果一致,所以选副作用更小的那条:**不阻断默认行为,只搬焦点**。
中文/日文输入同理:`keyCode === 229`(`key === "Process"`)这种「交给输入法」的键
也会把焦点交过去,但**绝不 preventDefault**,输入法组字不受干扰。
### 光标落在哪里(已有草稿时)
只做 DOM `focus()` 有个坑:内容可编辑元素会**把光标放在草稿开头**,于是「接着写」变成
「插到最前面」。官方输入栏自己就注释过这一点,它的解法是「DOM focus + `editor.focus()`」——
Lexical 会把光标还原到用户上次的位置。
本插件照做,而且**不引入对 Lexical 的构建期依赖**:
1. DOM `focus({preventScroll:true})` 拿焦点;
2. 如果元素上挂着 Lexical 的编辑器实例(Lexical 在根元素上暴露 `__lexicalEditor`),
调它的 `focus()` 还原光标;异常就忽略;
3. 没有编辑器实例(元素不是 Lexical、或将来被换掉)时,退化为**把光标放到草稿末尾**,
保证「接着写」仍是追加而不是插队。
三种情形都在测试里断言过(无句柄追加到末尾 / 有句柄则委托给它并落在它恢复的位置)。
### 粘贴:同样只搬焦点
粘贴不能照搬打字的做法。官方的粘贴逻辑是注册在**输入框自己**身上的一条 Lexical 命令
(`PASTE_COMMAND`):它从 `event.clipboardData` 取文本、走 `intakeFiles` 收图片/文件、
再给这次粘贴打一个历史标记。所以只要 **paste 事件能落在输入框上**,官方的全套行为
(文字、截图、文件、撤销粒度)都会原样生效,我们一行都不用重写。
于是分两条路:
1. **⌘V / Ctrl+V / Shift+Insert 的 keydown** → 只把焦点交给输入框,**不 preventDefault**。
浏览器随后发起的粘贴自然落在刚获得焦点的输入框上。
(实测:`tools/paste-to-compose-probe.mjs` 里 `focus` 变体的 paste 目标是 `composer`,
对照组 `none` 落在 `BODY` —— 这正是「粘贴没激活输入框」的原因。)
2. **没有 keydown 的粘贴**(原生 Edit 菜单粘贴、鼠标中键粘贴)→ document 级 `paste`
监听兜底:`preventDefault` 后把焦点交给输入框,并把**原始 `clipboardData`** 重新投递给
它(`new ClipboardEvent('paste', { clipboardData })`),因此图片/文件同样不丢;
只有当输入框没有消费这次事件时,才退化成写入纯文本。
如果第 1 条已经生效,paste 事件本身就落在输入框上,这条兜底会因为「目标就是输入框」直接返回,不会粘两次。
### 哪些键不接管
- 除粘贴(⌘V / Ctrl+V / Shift+Insert)以外的 ⌘/Ctrl/Alt 组合键;
- 一切非单字符键:Tab、Enter、Esc、方向键、退格、功能键、死键(`Dead`);
- **空格**:空格是文本键,但也是「往下翻页」的常用键。只聚焦会把「翻页」变成
「以空格开头的新消息」,所以默认让路。想连空格一起接管,把 `client.js` 里的
`DEFER_SPACE` 改成 `false` 即可(输入框已聚焦时不受影响,空格照常输入)。
### 多个对话面板
同一个窗口里可能有多个会话面板(例如嵌入式子会话),各自带一个 `[data-composer-input]`。
插件只会选**当前可写且已排版**(`isContentEditable` + `getClientRects()` 非空)的那个,
并在有多个候选时优先选**用户最后点过或聚焦过的面板**(`pointerdown` / `focusin` 追踪,
`[data-conversation-session]` 作为面板边界);隐藏的面板不会被选中。
---
## 验证
### 1. 真实引擎下的行为测试:`tools/test-type-to-compose.mjs`
把 `client.js` **原样**加载进一个 DSH 外壳的 DOM 副本(侧边栏按钮、可见对话栏、
可显示/隐藏的第二栏、搜索框、xterm 式终端、可开关的 dialog),再用 Chrome DevTools
Protocol 派发**真实按键**,逐条断言:
```bash
# Node ≥ 21(脚本用到全局 fetch / WebSocket);本机没装 node 时也可以用 DSH 自带的:
node tools/test-type-to-compose.mjs
# NODE="/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node"
# "$NODE" tools/test-type-to-compose.mjs
```
当前结果:**43/43 通过,退出码 0**。覆盖:页面空白/侧边栏按钮上打字进输入框;
其它输入控件与终端不被抢;空格让路;⌘/Ctrl 组合键与 Tab/Enter/Esc/方向键不接管;
隐藏 dialog 不阻断、可见 dialog 接管键盘;输入框不可编辑时不动焦点;
双栏按最近点击选择且隐藏栏被跳过;已聚焦输入框不重复插入;Shift 大写;
**已有草稿时的光标位置(无句柄追加末尾 / 有句柄委托 `editor.focus()`)**;
**⌘V 后 paste 落在输入框、Shift+Insert 同样生效、无 keydown 的粘贴被转发且复用原始
`clipboardData`、粘贴在别的输入框/弹窗打开时不抢**;
IME 起始键(229)只搬焦点不写字;卸载后不再接管;全过程无控制台错误。
> 测试页里有一个**测试专用的** `preventDefault` 兜底监听,用来规避一个 Chromium
> DevTools 注入按键的怪癖:headless 下未被消费的 keydown 会无限自动重复。
> 它注册在插件监听**之后**,所以插件看到的仍是 `defaultPrevented === false`,
> 不影响被测行为。
### 2. 机制对照实验
- `tools/type-to-compose-probe.mjs`:上面「只聚焦 vs 手动插入」那张表的来源。
- `tools/paste-to-compose-probe.mjs`:粘贴落点对照(`--headful` 用真实系统剪贴板):
| 变体(真实剪贴板) | paste 落在哪 | 输入框内容 |
|---|---|---|
| 不接管 | `BODY`(有数据但没人接) | `""` ← 就是「粘贴没激活输入框」的现象 |
| 按下 ⌘V 时先交出焦点 | `composer` | `"PASTED-TEXT-中文"` |
| 没有 keydown,靠 document 监听转发 | `BODY` → 转发到 `composer` | `"PASTED-TEXT-中文"` |
| 目标本来就是别的输入框 | 留在原输入框 | 不抢 |
> 这条实验解释了为什么两条路都要有:浏览器/Web 表面与 Windows 桌面把 ⌘V 作为按键送到页面,
> 「先聚焦」那条就够;macOS 桌面的原生 Edit 菜单会先吃掉 ⌘V 加速键,页面只收到一个落在
> `BODY` 上的 `paste` 事件(Electron 的 Paste 角色最终调用的正是同一个 Blink 命令),
> 这时靠 document 监听兜底转发。
> headless Chrome 没有剪贴板(内容为空),所以真实内容断言用 `--headful` 跑;
> headless 下可测的信号是「paste 落在哪个元素」。正文内容另由
> `tools/test-type-to-compose.mjs` 用构造的 `ClipboardEvent` 断言。
---
## 已知边界
- 依赖 `[data-composer-input]` 这个锚点(官方为「输入框宿主」专门打的数据属性,
不含构建哈希)。若上游改名,插件会静默停止生效 —— 不会报错、也不会破坏界面。
- 只在浏览器/Web 表面生效;宿主侧模块是空实现(每个 profile 行都需要一个 Node 侧模块)。
- 空格默认让路(见上,一行开关)。
- 目前 DSH 没有任何「无修饰键的全局单键快捷键」(已核对:只有方向键/Enter/Esc
以及带修饰键的组合),所以接管单字符键不会遮蔽官方快捷键;
将来若新增,请把它加进跳过名单。
---
## 仓库结构
```
client.js 浏览器侧:全部逻辑(唯一有行为的文件)
index.js 宿主侧:空实现(每个 profile bundle 都需要一个 Node 侧模块)
cordis.patch.yml 以 profile bundle 层挂载本包
package.json 含 dsh.bundle.patch / dsh.client 声明
README.md / README.en.md 中英文说明
LICENSE MIT
tools/test-type-to-compose.mjs 真实 Chrome + 真实按键的行为测试(43 项)
tools/type-to-compose-probe.mjs 打字:只聚焦 vs 手动插入 的对照实验
tools/paste-to-compose-probe.mjs 粘贴:paste 落点与 --headful 真实剪贴板实验
```
Install
dsh plugin --profile web add github:daoyu1993-lab/dsh-plugin-type-to-compose
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-plugin-type-to-compose from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.