dsh-multiline-input 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/ROADMAP.md +23 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1-single-line.png +0 -0
- package/assets/screenshot-2-multiline.png +0 -0
- package/client.js +571 -0
- package/cordis.patch.yml +9 -0
- package/docs/PLAN-2026-10-27.md +215 -0
- package/docs/RELEASE-CHECKLIST-0.1.0.md +60 -0
- package/icon.svg +4 -0
- package/index.js +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/screenshots.json +4 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
首个版本。
|
|
6
|
+
|
|
7
|
+
- 对话输入框单行 / 多行双态切换,模式按设备持久化。
|
|
8
|
+
- 通过 DSH 官方快捷键服务注册 `multiline-input.toggle`:桌面默认 `Cmd+E`(macOS)/ `Ctrl+E`(Windows / Linux),可在「设置 → 通用 → 快捷键」改绑、恢复默认、查看冲突。
|
|
9
|
+
- 卡片顶部常驻提示条(与卡片同底色、底部淡分割线):实时显示当前生效键位与目标模式;点击可切换。
|
|
10
|
+
- 多行模式最小高度 4 行,内容变多自动长高(沿用 DSH 的 336px 滚动上限)。
|
|
11
|
+
- 多行模式 Enter 换行、Shift+Enter 发送;`Cmd/Ctrl+Enter`、斜杠 / 引用菜单、输入法组合态保持原有语义。
|
|
12
|
+
- 卸载 / 禁用后恢复原生输入框,无 DOM、样式、监听残留。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZilongYang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-multiline-input
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 DeepSeek Harness 的对话输入框在**单行 / 多行**之间切换的 Web 客户端插件。
|
|
4
|
+
|
|
5
|
+
- 默认快捷键 **Cmd+E(macOS)/ Ctrl+E(Windows / Linux)**;输入框上沿有一个与输入框同底色的小 **tab** 显示当前快捷键与目标模式,点击即可切换。
|
|
6
|
+
- 多行模式输入框默认 **4 行高**(3–5 行区间内),内容变多自动长高,到原生 336px 上限后滚动。
|
|
7
|
+
- 多行模式 **Enter 换行、Shift+Enter 发送**;单行模式保持 DSH 原生行为(Enter 发送、Shift+Enter 换行)。
|
|
8
|
+
- 快捷键可在 **设置 → 通用 → 快捷键**里改成任意组合(冲突校验、恢复默认都由 DSH 官方界面负责)。
|
|
9
|
+
- 模式按设备记住,刷新 / 重启后保持。
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# 市场 / CLI 管理的 profile
|
|
19
|
+
dsh plugin --profile <profile> add dsh-multiline-input
|
|
20
|
+
|
|
21
|
+
# 或在应用内「插件」页搜索 / 用绝对路径安装后启用
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
桌面版使用的 `desktop` profile 由应用独占(`dsh plugin --profile desktop` 会被拒绝),请在应用「插件」页安装或手动 link:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
cd ~/.dsh/profiles/desktop
|
|
28
|
+
pnpm add link:/absolute/path/to/dsh-multiline-input
|
|
29
|
+
# 再把 "dsh-multiline-input" 追加到 package.json 的 dsh.profile.bundles
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
启用后刷新页面即可;插件清单变化不需要重启应用。
|
|
33
|
+
|
|
34
|
+
**卸载**:插件页关闭 / 卸载,或 `dsh plugin --profile <profile> remove dsh-multiline-input`。
|
|
35
|
+
|
|
36
|
+
## 使用
|
|
37
|
+
|
|
38
|
+
| 操作 | 单行模式 | 多行模式 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `Enter` | 发送 | 换行 |
|
|
41
|
+
| `Shift+Enter` | 换行 | 发送 |
|
|
42
|
+
| `Cmd/Ctrl+Enter` | 加速提交(不变) | 加速提交(不变) |
|
|
43
|
+
| `/`、`@` 菜单打开时的 `Enter` | 选中菜单项 | 选中菜单项 |
|
|
44
|
+
| 输入法候选态的 `Enter` | 交给输入法 | 交给输入法 |
|
|
45
|
+
|
|
46
|
+
- 按 `Cmd+E` / `Ctrl+E`(或点击 tab)在两种模式间循环;切换不清空草稿、不抢焦点。
|
|
47
|
+
- tab **常驻显示**,输入文字时也不隐藏;多行时显示 `Shift+Enter 提交 · Cmd+E 单行输入`,单行时显示 `Cmd+E 多行输入`。
|
|
48
|
+
|
|
49
|
+
## 修改快捷键
|
|
50
|
+
|
|
51
|
+
1. 打开 **设置 → 通用 → 快捷键**;
|
|
52
|
+
2. 搜索「多行输入」(命令名:**切换多行输入**,id `multiline-input.toggle`);
|
|
53
|
+
3. 点击右侧键位进入录制,按下新组合即可;冲突时官方界面会提示,可随时恢复默认。
|
|
54
|
+
|
|
55
|
+
tab 实时跟随当前生效的组合;把绑定清空后只显示「多行输入 / 单行输入」,点击仍可切换。
|
|
56
|
+
|
|
57
|
+
## 与相近插件的区别
|
|
58
|
+
|
|
59
|
+
| 插件 | 它做什么 | 本插件 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `dsh-composer-expand` | 工具行按钮把输入框撑到 70vh | 双模式 + 快捷键 + Enter/Shift+Enter 互换 + 常驻 tab |
|
|
62
|
+
| `dsh-fullscreen-input` | 另开全屏面板写多行,Enter 换行、Ctrl+Enter 发送 | 内联在原生输入框,多行时 Shift+Enter 发送(带输入法保护) |
|
|
63
|
+
| `dsh-ctrl-enter-submit` | 全局把 Enter 改成换行、Ctrl/Cmd+Enter 发送 | 单行保持原生 Enter 发送,多行才交换 |
|
|
64
|
+
| `dsh-composer-ux` | 可配置发送/换行键位 + 右键菜单 + 快捷指令面板 | 只专注输入模式与高度 |
|
|
65
|
+
| `dsh-keyboard-manager` | Ctrl+Q / **Ctrl+E** / Ctrl+S 切栏、开设置 | Windows/Linux 上它的 Ctrl+E 与本插件默认冲突;可在设置里改键(macOS 本插件用 Cmd+E,不冲突) |
|
|
66
|
+
|
|
67
|
+
## 实现说明
|
|
68
|
+
|
|
69
|
+
插件遵守 DSH 的第三方客户端插件契约(`dsh.client` + `dsh.bundle.patch`),Host 半只保留一个可安装 / 启停的 bundle row,功能全部在 `client.js`:
|
|
70
|
+
|
|
71
|
+
- **模式状态**:模块级 store + `localStorage['dsh-multiline-input.mode.v1']`,投影为 `<html data-dsh-ml-input="multi">`;
|
|
72
|
+
- **高度**:一条 CSS 规则给 `[data-composer-card] [data-composer-input][contenteditable="true"]` 设 `min-height: calc(4 * 1lh + 8px)`,不碰 DSH 的滚动上限;
|
|
73
|
+
- **tab 提示**:注册到 `conversation.input.overlay` 座位,绝对定位到卡片上沿右侧(`bottom: 100%`,同底色 + 底部 0.5px 细线),宽度随文字;
|
|
74
|
+
- **Enter / Shift+Enter**:在 document 捕获阶段拦下多行模式下的 `Enter`,向编辑器重放一个翻转了 `shiftKey` 的合成按键——仍由 DSH 自己的 composer keymap 决定「换行」还是「提交」,撤销历史、输入法保护、busy-enter(queue/steer)、菜单仲裁全部保持原逻辑;
|
|
75
|
+
- **快捷键**:通过 `ctx.shortcuts.register()` 注册命令,编辑与持久化完全交给 DSH。
|
|
76
|
+
|
|
77
|
+
源码依据(DSH `0.2.1-rc.3`,commit `22ddee3`):
|
|
78
|
+
|
|
79
|
+
| 契约 | 源码文件 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| 输入卡片 / 覆盖层锚点 | `packages/client/ui-conversation/src/client/skeleton/InputBar.tsx` |
|
|
82
|
+
| 可编辑面 / 占位符 | `packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx`、`ComposerContentEditable.tsx` |
|
|
83
|
+
| Enter 语义 | `packages/client/ui-conversation/src/client/input/editor/keymap.ts` |
|
|
84
|
+
| 公开输入动作面 | `packages/client/ui-conversation/src/client/contract/input.ts` |
|
|
85
|
+
| 座位与标准 props | `packages/client/ui-conversation/src/client/contract/slots.ts` |
|
|
86
|
+
| 快捷键命令契约 | `packages/client/shortcuts/src/client/types.ts`、`binding.ts` |
|
|
87
|
+
| 客户端清单 | `packages/util/package-manifest/src/types.ts`、`packages/client/modules/README.zh.md` |
|
|
88
|
+
|
|
89
|
+
## 兼容性
|
|
90
|
+
|
|
91
|
+
- 在 DSH **0.2.1-rc.3**(桌面版 / Web)验证;`dsh.compatibility.dsh` = `>=0.2.1-rc.3 <0.3.0-0`。
|
|
92
|
+
- 依赖 composer 的 `data-*` 锚点与 `role="listbox"`,属 DSH 内部实现;升级 DSH 后需要回归(锚点缺失时插件只 warn,不会崩)。
|
|
93
|
+
- Web + Linux 没有默认快捷键(DSH 对 Web 可选组合有白名单限制),需在设置里自行指定。
|
|
94
|
+
|
|
95
|
+
## 开发
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
dsh-multiline-input/
|
|
99
|
+
├── package.json # dsh.bundle.patch + dsh.client(platform: web)
|
|
100
|
+
├── cordis.patch.yml # 1 个 Host row
|
|
101
|
+
├── index.js # Host 半(空实现,仅为 bundle 契约)
|
|
102
|
+
├── client.js # Client 半(手写,无构建、无第三方依赖)
|
|
103
|
+
├── icon.svg / assets/ # 插件图标与市场截图
|
|
104
|
+
├── locale/{zh,en}.json # 插件展示元数据
|
|
105
|
+
├── test/ # node --test
|
|
106
|
+
└── docs/ # 计划、发版清单
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- 测试:`npm test`(`node --test`,当前 12 条用例)。
|
|
110
|
+
- 语法:`node --check client.js && node --check index.js`。
|
|
111
|
+
- 调试:HMR 默认忽略 `**/node_modules`,链接安装的 `client.js` 改动不会被热加载;改完客户端代码后在插件页关/开一次(或重启应用),再刷新页面。
|
|
112
|
+
|
|
113
|
+
## 已知限制
|
|
114
|
+
|
|
115
|
+
- 仅作用于 Web GUI(桌面版与浏览器),TUI / CLI 不适用。
|
|
116
|
+
- 多行起始高度是代码常量 4 行,暂不提供配置项。
|
|
117
|
+
- 若默认键位与其它插件的命令默认值冲突,插件会降级为「点击 tab 切换」并在控制台输出一次警告。
|
|
118
|
+
|
|
119
|
+
## 文档
|
|
120
|
+
|
|
121
|
+
- [实施计划(2026-10-27)](docs/PLAN-2026-10-27.md)
|
|
122
|
+
- [发版检查清单 0.1.0](docs/RELEASE-CHECKLIST-0.1.0.md)
|
|
123
|
+
- [路线图](ROADMAP.md)
|
|
124
|
+
- [变更记录](CHANGELOG.md)
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ROADMAP
|
|
2
|
+
|
|
3
|
+
## 0.1.0(当前)
|
|
4
|
+
|
|
5
|
+
- [x] 单行 / 多行双态切换,模式按设备持久化(`localStorage`)
|
|
6
|
+
- [x] 官方快捷键命令(`multiline-input.toggle`,桌面默认 Ctrl+E,可在设置中改绑)
|
|
7
|
+
- [x] 右上角提示(仅空输入时显示,实时显示当前生效键位)
|
|
8
|
+
- [x] 多行模式 4 行起高,随内容长高,沿用 DSH 336px 滚动上限
|
|
9
|
+
- [x] 多行模式 Enter 换行 / Shift+Enter 发送,保留撤销、输入法、菜单、busy-enter 语义
|
|
10
|
+
- [x] 与 composer 其它输入框、设置弹窗、斜杠菜单互不干扰
|
|
11
|
+
- [x] 单元测试(`node --test`)与静态检查
|
|
12
|
+
|
|
13
|
+
## 0.2.0 候选
|
|
14
|
+
|
|
15
|
+
- [ ] 起始行数可配置(3–5 行),通过 Host `Config` + 插件详情页
|
|
16
|
+
- [ ] 按会话记忆模式(当前为全局 / 按设备)
|
|
17
|
+
- [ ] 若 DSH 提供公开的 composer 输入扩展点,替换现有的 DOM 事件接管
|
|
18
|
+
- [ ] DSH 下一个版本线的兼容回归(DOM 锚点 / `Shortcuts` 契约)
|
|
19
|
+
|
|
20
|
+
## 文档
|
|
21
|
+
|
|
22
|
+
- 计划:[docs/PLAN-2026-10-27.md](docs/PLAN-2026-10-27.md)
|
|
23
|
+
- 发版清单:[docs/RELEASE-CHECKLIST-0.1.0.md](docs/RELEASE-CHECKLIST-0.1.0.md)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-multiline-input.
|
|
3
|
+
*
|
|
4
|
+
* Single/multi-line composer toggle for the DSH Web GUI:
|
|
5
|
+
*
|
|
6
|
+
* - a shortcut command (default Ctrl+E) registered through `ctx.shortcuts`,
|
|
7
|
+
* so the binding appears in Settings → General → Keyboard shortcuts and
|
|
8
|
+
* every edit there is validated and persisted by DSH itself;
|
|
9
|
+
* - a top-right hint inside the composer card (`conversation.input.overlay`),
|
|
10
|
+
* shown only while the draft is empty;
|
|
11
|
+
* - a taller composer floor in multi-line mode, driven by
|
|
12
|
+
* `<html data-dsh-multiline-input="multi">` and one CSS rule;
|
|
13
|
+
* - Enter / Shift+Enter swapped in multi-line mode by re-dispatching the
|
|
14
|
+
* same keydown with the opposite `shiftKey`. DSH's own composer keymap
|
|
15
|
+
* keeps deciding newline vs submit, so undo history, the IME guard,
|
|
16
|
+
* busy-enter (queue / steer) and menu arbitration all stay intact.
|
|
17
|
+
*
|
|
18
|
+
* Hand-written bundle: the module loader materializes `factory(require)` and
|
|
19
|
+
* takes its return value as the module exports. React comes from the browser
|
|
20
|
+
* module table; no Harness Client package is imported.
|
|
21
|
+
*
|
|
22
|
+
* Reference contracts (source checkout, 0.2.1-rc.3):
|
|
23
|
+
* - packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
|
|
24
|
+
* - packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx
|
|
25
|
+
* - packages/client/ui-conversation/src/client/input/editor/keymap.ts
|
|
26
|
+
* - packages/client/ui-conversation/src/client/contract/input.ts
|
|
27
|
+
* - packages/client/ui-conversation/src/client/contract/slots.ts
|
|
28
|
+
* - packages/client/shortcuts/src/client/types.ts
|
|
29
|
+
* - packages/client/modules/README.zh.md
|
|
30
|
+
*/
|
|
31
|
+
window.__ModuleLoader__.load({
|
|
32
|
+
id: 'dsh-multiline-input',
|
|
33
|
+
factory(require) {
|
|
34
|
+
const React = require('react')
|
|
35
|
+
const h = React.createElement
|
|
36
|
+
|
|
37
|
+
/** Locale namespace and the style element id (a reload must not stack styles). */
|
|
38
|
+
const NS = 'dsh-multiline-input'
|
|
39
|
+
const STYLE_ID = 'dsh-multiline-input-style'
|
|
40
|
+
|
|
41
|
+
/** Device-local mode preference; the mode itself is not Session data. */
|
|
42
|
+
const MODE_STORAGE_KEY = 'dsh-multiline-input.mode.v1'
|
|
43
|
+
/** `<html>` marker read by the height rule. */
|
|
44
|
+
const MODE_ATTRIBUTE = 'data-dsh-multiline-input'
|
|
45
|
+
|
|
46
|
+
/** Shortcut command id; dotted form so DSH can persist user overrides. */
|
|
47
|
+
const COMMAND_ID = 'multiline-input.toggle'
|
|
48
|
+
/** Composer seats: after the slash menu (order 0), above the card content. */
|
|
49
|
+
const HINT_ORDER = 10
|
|
50
|
+
/** Multi-line floor in text lines (the requested 3–5 range). */
|
|
51
|
+
const MIN_LINES = 4
|
|
52
|
+
|
|
53
|
+
const MODE_SINGLE = 'single'
|
|
54
|
+
const MODE_MULTI = 'multi'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default bindings. Desktop may bind any combination (the native keyboard
|
|
58
|
+
* bridge outranks editors). Web only accepts the documented allowlist, so
|
|
59
|
+
* it uses Primary+Shift+E; Linux Web has no allowed letter chord and stays
|
|
60
|
+
* unbound until the user picks one in Settings.
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULTS = {
|
|
63
|
+
'desktop:macos': { code: 'KeyE', modifiers: ['primary'] },
|
|
64
|
+
'desktop:windows': { code: 'KeyE', modifiers: ['primary'] },
|
|
65
|
+
'desktop:linux': { code: 'KeyE', modifiers: ['primary'] },
|
|
66
|
+
'web:macos': { code: 'KeyE', modifiers: ['primary', 'shift'] },
|
|
67
|
+
'web:windows': { code: 'KeyE', modifiers: ['primary', 'shift'] },
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DICT = {
|
|
71
|
+
zh: {
|
|
72
|
+
toMulti: '多行输入',
|
|
73
|
+
toSingle: '单行输入',
|
|
74
|
+
submitHint: 'Shift+Enter 提交',
|
|
75
|
+
toggleLabel: '切换多行输入',
|
|
76
|
+
hintTitle: '点击切换输入模式;快捷键可在「设置 → 通用 → 快捷键」中修改',
|
|
77
|
+
ariaLabel: '{action}({keys})',
|
|
78
|
+
},
|
|
79
|
+
en: {
|
|
80
|
+
toMulti: 'Multiline input',
|
|
81
|
+
toSingle: 'Single-line input',
|
|
82
|
+
submitHint: 'Shift+Enter to send',
|
|
83
|
+
toggleLabel: 'Toggle multiline input',
|
|
84
|
+
hintTitle: 'Click to switch the input mode. Edit the shortcut in Settings → General → Keyboard shortcuts.',
|
|
85
|
+
ariaLabel: '{action} ({keys})',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CSS = `
|
|
90
|
+
/* A small tab riding on the card's top shoulder: same fill as the card, so it
|
|
91
|
+
reads as one piece, with the card's own hairline continuing under it. */
|
|
92
|
+
.dmi-hint {
|
|
93
|
+
position: absolute;
|
|
94
|
+
right: calc(var(--dsw-radius-panel) + 4px);
|
|
95
|
+
bottom: 100%;
|
|
96
|
+
z-index: 2;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: flex-end;
|
|
100
|
+
box-sizing: border-box;
|
|
101
|
+
/* Hug the text: the tab and its hairline only span the hint. */
|
|
102
|
+
width: max-content;
|
|
103
|
+
max-width: 100%;
|
|
104
|
+
height: 24px;
|
|
105
|
+
padding: 0 12px;
|
|
106
|
+
background: var(--dsw-specific-input-major);
|
|
107
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
|
|
108
|
+
border-radius: var(--dsw-radius-md) var(--dsw-radius-md) 0 0;
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
white-space: nowrap;
|
|
111
|
+
}
|
|
112
|
+
.dmi-hint-button {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 6px;
|
|
116
|
+
margin: 0;
|
|
117
|
+
padding: 1px 6px;
|
|
118
|
+
border: 0;
|
|
119
|
+
border-radius: var(--dsw-radius-sm, 6px);
|
|
120
|
+
background: transparent;
|
|
121
|
+
color: var(--dsw-alias-label-tertiary);
|
|
122
|
+
font: inherit;
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
line-height: 18px;
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
pointer-events: auto;
|
|
127
|
+
}
|
|
128
|
+
.dmi-hint-button:hover {
|
|
129
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
130
|
+
color: var(--dsw-alias-label-secondary);
|
|
131
|
+
}
|
|
132
|
+
.dmi-hint-key {
|
|
133
|
+
color: var(--dsw-alias-label-secondary);
|
|
134
|
+
font-weight: 500;
|
|
135
|
+
}
|
|
136
|
+
.dmi-hint-sep {
|
|
137
|
+
color: var(--dsw-alias-label-caption);
|
|
138
|
+
opacity: 0.6;
|
|
139
|
+
}
|
|
140
|
+
html[${MODE_ATTRIBUTE}="multi"] [data-composer-card] [data-composer-input][contenteditable="true"] {
|
|
141
|
+
min-height: calc(${MIN_LINES} * 1lh + 8px);
|
|
142
|
+
}
|
|
143
|
+
@supports not (height: 1lh) {
|
|
144
|
+
html[${MODE_ATTRIBUTE}="multi"] [data-composer-card] [data-composer-input][contenteditable="true"] {
|
|
145
|
+
min-height: ${MIN_LINES * 24 + 8}px;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
@media (max-width: 720px) {
|
|
149
|
+
.dmi-hint-sep,
|
|
150
|
+
.dmi-hint-meta {
|
|
151
|
+
display: none;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
`
|
|
155
|
+
|
|
156
|
+
//#region Pure helpers (also exported for node --test)
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Coerce any stored value into a known mode.
|
|
160
|
+
* @param value - raw preference value.
|
|
161
|
+
* @returns the normalized mode.
|
|
162
|
+
*/
|
|
163
|
+
function normalizeMode(value) {
|
|
164
|
+
return value === MODE_MULTI ? MODE_MULTI : MODE_SINGLE
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Cycle between the two modes.
|
|
169
|
+
* @param mode - current mode.
|
|
170
|
+
* @returns the other mode.
|
|
171
|
+
*/
|
|
172
|
+
function nextMode(mode) {
|
|
173
|
+
return normalizeMode(mode) === MODE_MULTI ? MODE_SINGLE : MODE_MULTI
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the persisted mode, treating unavailable storage as single-line.
|
|
178
|
+
* @param store - `localStorage`-like object, or undefined.
|
|
179
|
+
* @returns the stored mode or the default.
|
|
180
|
+
*/
|
|
181
|
+
function readMode(store) {
|
|
182
|
+
try {
|
|
183
|
+
return normalizeMode(store?.getItem(MODE_STORAGE_KEY))
|
|
184
|
+
} catch {
|
|
185
|
+
return MODE_SINGLE
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Persist one mode; a blocked storage backend only costs the preference.
|
|
191
|
+
* @param store - `localStorage`-like object, or undefined.
|
|
192
|
+
* @param mode - mode to persist.
|
|
193
|
+
*/
|
|
194
|
+
function writeMode(store, mode) {
|
|
195
|
+
try {
|
|
196
|
+
store?.setItem(MODE_STORAGE_KEY, normalizeMode(mode))
|
|
197
|
+
} catch {
|
|
198
|
+
/* Preference persistence is best-effort. */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Project the mode onto the document root consumed by the height rule.
|
|
204
|
+
* @param element - document root, or null in non-DOM tests.
|
|
205
|
+
* @param mode - mode to apply.
|
|
206
|
+
*/
|
|
207
|
+
function applyModeAttribute(element, mode) {
|
|
208
|
+
if (element === null || element === undefined) return
|
|
209
|
+
if (typeof element.setAttribute !== 'function' || typeof element.removeAttribute !== 'function') return
|
|
210
|
+
if (normalizeMode(mode) === MODE_MULTI) element.setAttribute(MODE_ATTRIBUTE, MODE_MULTI)
|
|
211
|
+
else element.removeAttribute(MODE_ATTRIBUTE)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const MODIFIER_LABELS = { control: 'Ctrl', meta: 'Cmd', alt: 'Alt', shift: 'Shift' }
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Human label for one physical key code (`KeyE` → `E`, `Digit1` → `1`).
|
|
218
|
+
* @param code - KeyboardEvent.code value.
|
|
219
|
+
* @returns the key label, or an empty string.
|
|
220
|
+
*/
|
|
221
|
+
function keyLabel(code) {
|
|
222
|
+
if (typeof code !== 'string' || code === '') return ''
|
|
223
|
+
return code.replace(/^Key/u, '').replace(/^Digit/u, '')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Render a binding as `Ctrl+E` style text.
|
|
228
|
+
* @param binding - `{ code, modifiers }` or null.
|
|
229
|
+
* @param platform - `macos`, `windows`, or `linux`.
|
|
230
|
+
* @returns the label, or null when there is no binding.
|
|
231
|
+
*/
|
|
232
|
+
function formatShortcut(binding, platform) {
|
|
233
|
+
if (binding === null || binding === undefined || typeof binding.code !== 'string') return null
|
|
234
|
+
const modifiers = Array.isArray(binding.modifiers) ? binding.modifiers : []
|
|
235
|
+
const labels = modifiers.map((modifier) => {
|
|
236
|
+
if (modifier === 'primary') return platform === 'macos' ? 'Cmd' : 'Ctrl'
|
|
237
|
+
return MODIFIER_LABELS[modifier] ?? modifier
|
|
238
|
+
})
|
|
239
|
+
const key = keyLabel(binding.code)
|
|
240
|
+
if (key === '') return null
|
|
241
|
+
return [...labels, key].join('+')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Find this command in the DSH shortcut catalog and render its binding.
|
|
246
|
+
* @param rows - `ctx.shortcuts.catalog` snapshot.
|
|
247
|
+
* @param platform - `ctx.shortcuts.platform`.
|
|
248
|
+
* @returns the label, or null when unbound / not registered yet.
|
|
249
|
+
*/
|
|
250
|
+
function labelFromCatalog(rows, platform) {
|
|
251
|
+
if (!Array.isArray(rows)) return null
|
|
252
|
+
const row = rows.find((entry) => entry !== null && entry !== undefined && entry.id === COMMAND_ID)
|
|
253
|
+
if (row === undefined) return null
|
|
254
|
+
return formatShortcut(row.binding, platform)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Decide whether the multi-line keymap should take over one keydown.
|
|
259
|
+
* @param input - flattened event and composer facts.
|
|
260
|
+
* @returns `newline` (Enter), `submit` (Shift+Enter), or null to pass.
|
|
261
|
+
*/
|
|
262
|
+
function shouldIntercept(input) {
|
|
263
|
+
const {
|
|
264
|
+
mode, key, shiftKey, ctrlKey, metaKey, altKey,
|
|
265
|
+
isComposing, keyCode, inComposer, editable, phase, menuOpen,
|
|
266
|
+
} = input
|
|
267
|
+
if (normalizeMode(mode) !== MODE_MULTI) return null
|
|
268
|
+
if (key !== 'Enter') return null
|
|
269
|
+
if (ctrlKey === true || metaKey === true || altKey === true) return null
|
|
270
|
+
if (isComposing === true || keyCode === 229) return null
|
|
271
|
+
if (inComposer !== true || editable !== true) return null
|
|
272
|
+
if (phase !== 'plain') return null
|
|
273
|
+
if (menuOpen === true) return null
|
|
274
|
+
return shiftKey === true ? 'submit' : 'newline'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
|
|
279
|
+
//#region Stores and DOM helpers
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Minimal snapshot store: one value, stable identity until it moves.
|
|
283
|
+
* @param initial - first snapshot.
|
|
284
|
+
* @returns a store consumable by `useSyncExternalStore`.
|
|
285
|
+
*/
|
|
286
|
+
function createValueStore(initial) {
|
|
287
|
+
let value = initial
|
|
288
|
+
const listeners = new Set()
|
|
289
|
+
return {
|
|
290
|
+
getSnapshot: () => value,
|
|
291
|
+
subscribe(listener) {
|
|
292
|
+
listeners.add(listener)
|
|
293
|
+
return () => {
|
|
294
|
+
listeners.delete(listener)
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
set(next) {
|
|
298
|
+
if (Object.is(next, value)) return
|
|
299
|
+
value = next
|
|
300
|
+
for (const listener of [...listeners]) listener()
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const COMPOSER_CARD = '[data-composer-card]'
|
|
306
|
+
const COMPOSER_INPUT = '[data-composer-input]'
|
|
307
|
+
const COMPOSER_OVERLAY = '[data-composer-overlay]'
|
|
308
|
+
const TRIGGER_MENU = '[role="listbox"]'
|
|
309
|
+
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable="true"], [contenteditable=""]'
|
|
310
|
+
|
|
311
|
+
/** @returns the device `localStorage`, or undefined when blocked. */
|
|
312
|
+
function localStore() {
|
|
313
|
+
try {
|
|
314
|
+
return window.localStorage
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** @returns whether the page runs inside the Desktop shell. */
|
|
321
|
+
function isDesktopShell() {
|
|
322
|
+
try {
|
|
323
|
+
return window.dshDesktop !== undefined
|
|
324
|
+
} catch {
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @returns `macos`, `windows`, or `linux`, from the visiting device. */
|
|
330
|
+
function osPlatform() {
|
|
331
|
+
try {
|
|
332
|
+
const platform = typeof navigator.userAgentData?.platform === 'string'
|
|
333
|
+
? navigator.userAgentData.platform
|
|
334
|
+
: navigator.platform
|
|
335
|
+
if (/mac/i.test(platform)) return 'macos'
|
|
336
|
+
if (/win/i.test(platform)) return 'windows'
|
|
337
|
+
return 'linux'
|
|
338
|
+
} catch {
|
|
339
|
+
return 'linux'
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @returns the label to show before the shortcut catalog is ready. */
|
|
344
|
+
function initialShortcutLabel() {
|
|
345
|
+
const platform = osPlatform()
|
|
346
|
+
const profile = `${isDesktopShell() ? 'desktop' : 'web'}:${platform}`
|
|
347
|
+
return formatShortcut(DEFAULTS[profile] ?? null, platform)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
//#endregion
|
|
351
|
+
|
|
352
|
+
//#region Hint component
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Composer hint on the right shoulder above the card: current shortcut plus
|
|
356
|
+
* the target mode, click to toggle. Always visible, so the binding stays
|
|
357
|
+
* discoverable while typing.
|
|
358
|
+
* @param props - injected stores, dictionary and toggle callback.
|
|
359
|
+
* @returns the hint element.
|
|
360
|
+
*/
|
|
361
|
+
function MultiLineHint({ t, modeStore, shortcutStore, toggle }) {
|
|
362
|
+
const mode = React.useSyncExternalStore(modeStore.subscribe, modeStore.getSnapshot, modeStore.getSnapshot)
|
|
363
|
+
const shortcut = React.useSyncExternalStore(shortcutStore.subscribe, shortcutStore.getSnapshot, shortcutStore.getSnapshot)
|
|
364
|
+
const action = mode === MODE_MULTI ? t('toSingle') : t('toMulti')
|
|
365
|
+
const meta = mode === MODE_MULTI ? t('submitHint') : null
|
|
366
|
+
return h(
|
|
367
|
+
'div',
|
|
368
|
+
{ className: 'dmi-hint' },
|
|
369
|
+
h(
|
|
370
|
+
'button',
|
|
371
|
+
{
|
|
372
|
+
type: 'button',
|
|
373
|
+
className: 'dmi-hint-button',
|
|
374
|
+
title: t('hintTitle'),
|
|
375
|
+
'aria-label': shortcut === null ? action : t('ariaLabel', { action, keys: shortcut }),
|
|
376
|
+
onMouseDown: (event) => {
|
|
377
|
+
event.preventDefault()
|
|
378
|
+
},
|
|
379
|
+
onClick: (event) => {
|
|
380
|
+
event.preventDefault()
|
|
381
|
+
event.stopPropagation()
|
|
382
|
+
toggle()
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
meta !== null && h('span', { className: 'dmi-hint-meta' }, meta),
|
|
386
|
+
meta !== null && h('span', { className: 'dmi-hint-sep', 'aria-hidden': true }, '·'),
|
|
387
|
+
shortcut !== null && h('span', { className: 'dmi-hint-key' }, shortcut),
|
|
388
|
+
h('span', { className: 'dmi-hint-label' }, action),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
//#endregion
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Register the dictionaries, styles, stores, keyboard takeover, shortcut
|
|
397
|
+
* command and the composer hint.
|
|
398
|
+
* @param ctx - client plugin context.
|
|
399
|
+
*/
|
|
400
|
+
function apply(ctx) {
|
|
401
|
+
ctx.effect(() => ctx.locale.register(NS, DICT), 'ml-input: dictionaries')
|
|
402
|
+
|
|
403
|
+
ctx.effect(() => {
|
|
404
|
+
const element = document.createElement('style')
|
|
405
|
+
element.id = STYLE_ID
|
|
406
|
+
element.setAttribute('data-plugin', NS)
|
|
407
|
+
element.textContent = CSS
|
|
408
|
+
document.head.appendChild(element)
|
|
409
|
+
return () => {
|
|
410
|
+
element.remove()
|
|
411
|
+
}
|
|
412
|
+
}, 'ml-input: styles')
|
|
413
|
+
|
|
414
|
+
const t = ctx.locale.bind(NS)
|
|
415
|
+
const modeStore = createValueStore(readMode(localStore()))
|
|
416
|
+
const shortcutStore = createValueStore(initialShortcutLabel())
|
|
417
|
+
|
|
418
|
+
const setMode = (next) => {
|
|
419
|
+
const normalized = normalizeMode(next)
|
|
420
|
+
modeStore.set(normalized)
|
|
421
|
+
writeMode(localStore(), normalized)
|
|
422
|
+
applyModeAttribute(document.documentElement, normalized)
|
|
423
|
+
}
|
|
424
|
+
const toggle = () => {
|
|
425
|
+
setMode(nextMode(modeStore.getSnapshot()))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
ctx.effect(() => {
|
|
429
|
+
applyModeAttribute(document.documentElement, modeStore.getSnapshot())
|
|
430
|
+
return () => {
|
|
431
|
+
applyModeAttribute(document.documentElement, MODE_SINGLE)
|
|
432
|
+
}
|
|
433
|
+
}, 'ml-input: mode attribute')
|
|
434
|
+
|
|
435
|
+
// Enter / Shift+Enter takeover. Capture on the document so the original
|
|
436
|
+
// keydown never reaches Lexical's root, then replay the opposite gesture
|
|
437
|
+
// on the editor; DSH's composer keymap owns both outcomes.
|
|
438
|
+
let replaying = false
|
|
439
|
+
ctx.effect(() => {
|
|
440
|
+
const onKeyDown = (event) => {
|
|
441
|
+
if (replaying || !(event instanceof KeyboardEvent)) return
|
|
442
|
+
const target = event.target instanceof Element
|
|
443
|
+
? event.target
|
|
444
|
+
: (document.activeElement instanceof Element ? document.activeElement : null)
|
|
445
|
+
if (target === null) return
|
|
446
|
+
const editor = target.closest(COMPOSER_INPUT)
|
|
447
|
+
if (editor === null || !(editor instanceof HTMLElement)) return
|
|
448
|
+
if (editor.getAttribute('contenteditable') !== 'true') return
|
|
449
|
+
const card = editor.closest(COMPOSER_CARD)
|
|
450
|
+
if (card === null) return
|
|
451
|
+
const decision = shouldIntercept({
|
|
452
|
+
mode: modeStore.getSnapshot(),
|
|
453
|
+
key: event.key,
|
|
454
|
+
shiftKey: event.shiftKey,
|
|
455
|
+
ctrlKey: event.ctrlKey,
|
|
456
|
+
metaKey: event.metaKey,
|
|
457
|
+
altKey: event.altKey,
|
|
458
|
+
isComposing: event.isComposing,
|
|
459
|
+
keyCode: event.keyCode,
|
|
460
|
+
inComposer: true,
|
|
461
|
+
editable: true,
|
|
462
|
+
phase: editor.getAttribute('data-phase'),
|
|
463
|
+
menuOpen: card.querySelector(`${COMPOSER_OVERLAY} ${TRIGGER_MENU}`) !== null,
|
|
464
|
+
})
|
|
465
|
+
if (decision === null) return
|
|
466
|
+
event.preventDefault()
|
|
467
|
+
event.stopPropagation()
|
|
468
|
+
replaying = true
|
|
469
|
+
try {
|
|
470
|
+
editor.dispatchEvent(new KeyboardEvent('keydown', {
|
|
471
|
+
key: 'Enter',
|
|
472
|
+
code: 'Enter',
|
|
473
|
+
bubbles: true,
|
|
474
|
+
cancelable: true,
|
|
475
|
+
composed: true,
|
|
476
|
+
shiftKey: decision === 'newline',
|
|
477
|
+
}))
|
|
478
|
+
} finally {
|
|
479
|
+
replaying = false
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
483
|
+
return () => {
|
|
484
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
485
|
+
}
|
|
486
|
+
}, 'ml-input: multiline enter')
|
|
487
|
+
|
|
488
|
+
// Shortcut command. The service may materialize after this plugin, so the
|
|
489
|
+
// registration waits for it; a conflicting default only costs the hotkey.
|
|
490
|
+
ctx.inject(['shortcuts'], (scope) => {
|
|
491
|
+
scope.effect(() => {
|
|
492
|
+
const refresh = () => {
|
|
493
|
+
shortcutStore.set(labelFromCatalog(scope.shortcuts.catalog.getSnapshot(), scope.shortcuts.platform))
|
|
494
|
+
}
|
|
495
|
+
const off = scope.shortcuts.catalog.subscribe(refresh)
|
|
496
|
+
refresh()
|
|
497
|
+
return () => {
|
|
498
|
+
off()
|
|
499
|
+
}
|
|
500
|
+
}, 'ml-input: shortcut label')
|
|
501
|
+
|
|
502
|
+
scope.effect(() => {
|
|
503
|
+
let dispose = () => {}
|
|
504
|
+
try {
|
|
505
|
+
dispose = scope.shortcuts.register({
|
|
506
|
+
id: COMMAND_ID,
|
|
507
|
+
label: () => t('toggleLabel'),
|
|
508
|
+
aliases: ['多行输入', '单行输入', 'multiline', 'multi-line', 'multiline-input', 'input mode'],
|
|
509
|
+
defaults: DEFAULTS,
|
|
510
|
+
regions: ['page', 'editable'],
|
|
511
|
+
modals: [],
|
|
512
|
+
resolve: (context) => {
|
|
513
|
+
if (context.modal !== null) return { status: 'pass' }
|
|
514
|
+
const target = context.target
|
|
515
|
+
if (target !== null
|
|
516
|
+
&& target.closest(COMPOSER_CARD) === null
|
|
517
|
+
&& target.closest(EDITABLE_SELECTOR) !== null) {
|
|
518
|
+
return { status: 'pass' }
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
status: 'handled',
|
|
522
|
+
run: () => {
|
|
523
|
+
toggle()
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
})
|
|
528
|
+
} catch (error) {
|
|
529
|
+
console.warn('[dsh-multiline-input] shortcut registration skipped:', error)
|
|
530
|
+
}
|
|
531
|
+
return () => {
|
|
532
|
+
dispose()
|
|
533
|
+
}
|
|
534
|
+
}, 'ml-input: shortcut command')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
|
|
538
|
+
name: 'conversation.input.overlay',
|
|
539
|
+
id: 'ml-input-hint',
|
|
540
|
+
order: HINT_ORDER,
|
|
541
|
+
locale: NS,
|
|
542
|
+
inject: () => ({ t, modeStore, shortcutStore, toggle }),
|
|
543
|
+
}, MultiLineHint))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
inject: ['slots', 'locale'],
|
|
548
|
+
apply,
|
|
549
|
+
/**
|
|
550
|
+
* Pure internals for `node --test`; the module loader ignores this key.
|
|
551
|
+
*/
|
|
552
|
+
__test: {
|
|
553
|
+
COMMAND_ID,
|
|
554
|
+
MODE_ATTRIBUTE,
|
|
555
|
+
MODE_STORAGE_KEY,
|
|
556
|
+
MODE_SINGLE,
|
|
557
|
+
MODE_MULTI,
|
|
558
|
+
MIN_LINES,
|
|
559
|
+
DEFAULTS,
|
|
560
|
+
normalizeMode,
|
|
561
|
+
nextMode,
|
|
562
|
+
readMode,
|
|
563
|
+
writeMode,
|
|
564
|
+
applyModeAttribute,
|
|
565
|
+
formatShortcut,
|
|
566
|
+
labelFromCatalog,
|
|
567
|
+
shouldIntercept,
|
|
568
|
+
},
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Bundle layer of dsh-multiline-input: one Host row.
|
|
2
|
+
#
|
|
3
|
+
# The feature is browser-only; this row exists so the package is a real bundle
|
|
4
|
+
# (the plugin manager refuses a package without a bundle patch) and so
|
|
5
|
+
# client-modules discovers the package's `dsh.client` declaration and serves
|
|
6
|
+
# `client.js` to the Web GUI.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: multiline-input
|
|
9
|
+
name: 'dsh-multiline-input'
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# dsh-ml-input 0.1.0 实施计划(按 DSH 源码修订)
|
|
2
|
+
|
|
3
|
+
> 本文件按获批时的内容原样存档,不再随实施更新;实施与计划的差异记录在文末「实施偏差(事后补记)」小节。
|
|
4
|
+
|
|
5
|
+
## 0. 先回答两个问题
|
|
6
|
+
|
|
7
|
+
**Q1:为什么之前把目标写成「DSH 0.2.1-rc.3 / 桌面版 / profile main / 52043」?**
|
|
8
|
+
写窄了,已改。目标应是 **DSH 插件契约本身**:一个遵循 `dsh.client` + Slots + Shortcuts 契约的第三方客户端插件,兼容当前发布线(源码 master 即 `0.2.1-rc.3`)。版本号只用于声明兼容边界与回归范围;`profile main`、端口、桌面版 2.0.20-next 只是本机验收环境,不再进入目标定义。
|
|
9
|
+
|
|
10
|
+
**Q2:为什么去拆 app.asar?**
|
|
11
|
+
我的错误。运行上下文把 app.asar 标成了 checkout,我顺手用了它;既然本机有完整源码克隆 `/Users/zilong/zWork/frontend/deepseek-harness`(master,`22ddee3`,2026-10-23,与运行版构建同日;根版本 `0.2.1-rc.3`),就该直接读源码。该路径已记入长期记忆;本修订版的所有依据都改为源码路径,不再引用解包结果。
|
|
12
|
+
|
|
13
|
+
## 1. 目标与验收标准
|
|
14
|
+
|
|
15
|
+
**目标**:纯客户端第三方插件 `dsh-ml-input`,不改 DSH 源码,把对话输入框在「单行(现状)/ 多行」之间循环切换:
|
|
16
|
+
|
|
17
|
+
1. 输入框右上角显示当前生效的切换快捷键(默认 `Ctrl+E`)+「多行输入」;输入框为空时显示,有草稿/附件时隐藏,避免压字。
|
|
18
|
+
2. 按快捷键循环:单行 → 多行 → 单行。
|
|
19
|
+
3. 多行模式默认高度 **4 行**(落在需求的 3–5 行区间),内容变多继续长高,到原生 336px 上限后滚动。
|
|
20
|
+
4. 多行模式下 **Enter 换行、Shift+Enter 提交**;单行模式保持 DSH 现状(Enter 提交、Shift+Enter 换行)。
|
|
21
|
+
5. 快捷键可在官方「设置 → 通用 → 快捷键」里改绑,提示实时跟随。
|
|
22
|
+
|
|
23
|
+
**验收标准**:
|
|
24
|
+
|
|
25
|
+
- 空输入右上角出现 `Ctrl+E 多行输入`;输入后消失,清空重现。
|
|
26
|
+
- `Ctrl+E` 在编辑器内、页面空白处都能切换;设置/其它输入框里不抢键。
|
|
27
|
+
- 多行最小高度 4 行;切回单行立即恢复;草稿不丢。
|
|
28
|
+
- 多行:Enter 换行(可撤销)、Shift+Enter 发送;`Cmd/Ctrl+Enter` 仍是加速提交;`/`、`@` 菜单打开时 Enter 仍是选中菜单项;输入法候选态不误触。
|
|
29
|
+
- 刷新后模式保持;设置里改键后提示立即变化、冲突有官方提示。
|
|
30
|
+
- 禁用/卸载后无 `<html data-dsh-ml-input>`、无 `<style>`、无键盘监听、无快捷键命令残留。
|
|
31
|
+
|
|
32
|
+
## 2. 源码依据(`/Users/zilong/zWork/frontend/deepseek-harness`,commit `22ddee3`)
|
|
33
|
+
|
|
34
|
+
| 事实 | 源码位置 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| 输入卡片 `<div data-composer-card>`(`position:relative`)与覆盖层锚点 `<div data-composer-overlay>`(`position:absolute; inset:0 0 auto; height:0`) | `packages/client/ui-conversation/src/client/skeleton/InputBar.tsx`(~423 行)、`InputBar.module.css`(`.overlayAnchor`) |
|
|
37
|
+
| 可编辑面是 Lexical contenteditable:`data-composer-input`、`role="textbox"`、`aria-multiline="true"`,带 `data-phase`、`data-placeholder` | `.../input/editor/ComposerContentEditable.tsx`、`.../input/editor/DraftEditor.tsx` |
|
|
38
|
+
| 占位符 `[data-composer-placeholder]` 只在 `draft === '' && 无附件 && 无命令 claim` 时渲染 | `.../input/editor/DraftEditor.tsx` + `InputBar.tsx` 的 `showPlaceholder` |
|
|
39
|
+
| 高度:`.input { min-height:36px }`、hero 52px;滚动上限 `--dsh-composer-text-max-height:336px`(约 14 行) | `InputBar.module.css`、`ConversationRoot.module.css`(~411 行) |
|
|
40
|
+
| Enter 语义:`KEY_ENTER_COMMAND` 注册在 `COMMAND_PRIORITY_CRITICAL`;**`event.shiftKey === true` 时 return false**(落到 `@lexical/plain-text` 的换行默认),否则提交 | `.../input/editor/keymap.ts`(~257-270 行,文件头注释写明该回退语义) |
|
|
41
|
+
| 公开输入动作面:`InputActions` 提供 `captureInsertion / insertText / setDraft / submit` 等 | `.../contract/input.ts`(~250-285 行) |
|
|
42
|
+
| Session 级座位标准 props:`sessionId`、`useSession`、`useConversation`、`useInput`、`inputActions`;`conversation.input.overlay` 为 `{kind:'list', scope:'session'}`("Floating entries rendered inside the resident composer card") | `.../contract/slots.ts`(~292、~350-365 行) |
|
|
43
|
+
| 斜杠/引用菜单在覆盖层内渲染 `[role="listbox"]`,且以 `[data-composer-card]` 为定位锚点 | `packages/client/ui-input-trigger/src/client/MenuView.tsx`(~86、132 行) |
|
|
44
|
+
| 快捷键命令契约:`ShortcutCommand{id,label,aliases,defaults,regions,modals,resolve}`;`ShortcutContext{source,region,modal,target}`;Web 白名单 `isWebBindingAllowed` 允许 `primary+shift+字母` | `packages/client/shortcuts/src/client/types.ts`、`packages/client/shortcuts/src/binding.ts`(~96-112 行)、`packages/client/shortcuts/README.zh.md` |
|
|
45
|
+
| 客户端插件清单:`DshClientManifest{platform, inject?, immediately?, external?}`;包需 `exports['./client']` + `dsh.bundle.patch`(无 bundle patch 会被标 `not-bundle`) | `packages/util/package-manifest/src/types.ts`、`packages/client/modules/README.zh.md`、`packages/boot/plugin-manager/src/index.ts`(~459 行) |
|
|
46
|
+
| 第三方手写 bundle 的可行样本:`dsh-session-titler`(`window.__ModuleLoader__.load({id, factory})`,React 走 `require('react')`,模块级 store + `useSyncExternalStore`) | 已装插件 `~/.dsh/profiles/web/node_modules/dsh-session-titler/client.js` |
|
|
47
|
+
|
|
48
|
+
补充:源码 master 与运行中的桌面版(2.0.20-next,内置 dsh 0.2.1-rc.3,构建于 10-23)为同一版本线;`ShortcutCommand` 所需的 `regions/modals/aliases` 都已核对。现有已装插件里没有占用 `KeyE` 的默认绑定(唯一命中是 `apiKeyEnv` 文本)。
|
|
49
|
+
|
|
50
|
+
## 3. 交付物(工作区即插件包根目录)
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
dsh-ml-input/
|
|
54
|
+
├── package.json # name=dsh-ml-input, dsh.bundle.patch + dsh.client
|
|
55
|
+
├── cordis.patch.yml # insert 一行 host 插件(id: ml-input)
|
|
56
|
+
├── index.js # Host 半:空实现,满足 bundle patch 契约
|
|
57
|
+
├── client.js # 手写 Client 半(状态 / 快捷键 / 提示 / 键位接管 / CSS)
|
|
58
|
+
├── icon.svg
|
|
59
|
+
├── locale/zh.json # {"meta":{title,description}}
|
|
60
|
+
├── locale/en.json
|
|
61
|
+
├── test/core.test.js # node --test
|
|
62
|
+
├── test/harness.js # Node 里的 __ModuleLoader__ 垫片
|
|
63
|
+
├── README.md / ROADMAP.md / CHANGELOG.md / LICENSE / .gitignore
|
|
64
|
+
└── docs/
|
|
65
|
+
├── PLAN-2026-10-27.md
|
|
66
|
+
└── RELEASE-CHECKLIST-0.1.0.md
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
无第三方依赖、无构建。插件仍放在 `zl-plugins/dsh-ml-input`(与 dsh-session-titler 等一致),DSH 仓库只作契约依据与回归参考。
|
|
70
|
+
|
|
71
|
+
## 4. 实现设计
|
|
72
|
+
|
|
73
|
+
### 4.1 清单与 Host 半
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"name": "dsh-ml-input",
|
|
78
|
+
"version": "0.1.0",
|
|
79
|
+
"type": "module",
|
|
80
|
+
"exports": { ".": "./index.js", "./client": "./client.js",
|
|
81
|
+
"./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
|
|
82
|
+
"dsh": {
|
|
83
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
84
|
+
"client": {
|
|
85
|
+
"platform": "web",
|
|
86
|
+
"immediately": true,
|
|
87
|
+
"inject": ["@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation"]
|
|
88
|
+
},
|
|
89
|
+
"compatibility": { "dsh": ">=0.2.1-rc.3 <0.3.0-0" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`cordis.patch.yml`:
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- insert:
|
|
98
|
+
- id: ml-input
|
|
99
|
+
name: 'dsh-ml-input'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`index.js` 只导出 `name` 与空的 `apply(){}`,让包成为可安装/启停的 bundle row;客户端半由 `dsh.client` 自动发现。
|
|
103
|
+
|
|
104
|
+
### 4.2 模式状态
|
|
105
|
+
|
|
106
|
+
- `'single' | 'multi'`,默认 `single`;`localStorage['dsh-ml-input.mode.v1']`(读写 try/catch,失败退化内存态)。
|
|
107
|
+
- 生效:`<html data-dsh-ml-input="multi">`,单行时移除;`apply()` 时同步设置,卸载时清理。
|
|
108
|
+
- 模块级 store:`getSnapshot / subscribe / toggle`,提示组件用 `useSyncExternalStore` 订阅(与 dsh-session-titler 同款)。
|
|
109
|
+
- 切换不清空草稿、不抢焦点;点提示时 `mousedown.preventDefault()` 保焦点。
|
|
110
|
+
|
|
111
|
+
### 4.3 快捷键(官方命令,可编辑)
|
|
112
|
+
|
|
113
|
+
- id `ml-input.toggle`(满足 `a.b` 持久化格式);label zh「切换多行输入」/ en「Toggle multiline input」;aliases 含「多行输入 / 单行输入 / multiline / ml-input」。
|
|
114
|
+
- 默认:`desktop:macos|windows|linux` = `{code:'KeyE', modifiers:['control']}`(显示 `Ctrl+E`);`web:macos|windows` = `{code:'KeyE', modifiers:['primary','shift']}`(Web 白名单允许);`web:linux` 不设默认。
|
|
115
|
+
- `regions: ['page','editable']`,`modals: []`。
|
|
116
|
+
- `resolve(context)`:`modal !== null` → pass;目标在 composer 之外的输入框 → pass(保留系统/浏览器原生 Ctrl+E);否则 handled → `toggle()`。
|
|
117
|
+
- 注册放在 `ctx.inject(['shortcuts'], scope => scope.effect(() => scope.shortcuts.register(...)))`,try/catch:与其它插件默认组合冲突时降级为「点击切换」+ console.warn,不让插件激活失败。
|
|
118
|
+
- 修改入口即官方设置:注册后自动出现在「设置 → 通用 → 快捷键」弹窗(可搜索、录制、冲突提示、按设备持久化)。
|
|
119
|
+
- 提示键位来自 `ctx.shortcuts.catalog` 快照,自绘格式(control→Ctrl、meta→Cmd、alt→Alt、shift→Shift,code 去 `Key` 前缀),macOS 也显示 `Ctrl+E`。
|
|
120
|
+
|
|
121
|
+
### 4.4 右上角提示
|
|
122
|
+
|
|
123
|
+
- 座位 `conversation.input.overlay`(list,`order:10`),渲染 `<button class="dml-hint">`。
|
|
124
|
+
- 可见性用标准 hook `useInput(s => s.draft === '' && s.attachmentIds.length === 0)`,与 `showPlaceholder` 语义一致;不做订阅机制。
|
|
125
|
+
- 内容:`<span class="dml-key">Ctrl+E</span><span class="dml-label">多行输入</span>`;多行模式加 `<span class="dml-meta">Shift+Enter 提交</span>`,label 变「单行输入」。
|
|
126
|
+
- 定位 `position:absolute; top:8px; right:12px`(相对 `[data-composer-overlay]`),12px 字,key 用 `--dsw-alias-label-secondary`、label 用 `--dsw-alias-label-tertiary`。
|
|
127
|
+
- 点击切换;`click.stopPropagation()` 防止 hero 卡片触发工作区选择;`title` 说明可在设置中修改。
|
|
128
|
+
|
|
129
|
+
### 4.5 多行高度
|
|
130
|
+
|
|
131
|
+
```css
|
|
132
|
+
html[data-dsh-ml-input="multi"] [data-composer-card] [data-composer-input] {
|
|
133
|
+
min-height: calc(4 * 1lh + 8px); /* 4 行正文 + 上下 padding;不支持 lh 时回退 calc(4 * 24px + 8px) */
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
只依赖 `data-*` 锚点;specificity 高于 `.input` 与 hero 的 52px;不提高 336px 的滚动上限(4 行起步、最多约 14 行后滚动)。
|
|
138
|
+
|
|
139
|
+
### 4.6 Enter / Shift+Enter
|
|
140
|
+
|
|
141
|
+
document 捕获阶段 keydown(`ctx.effect` 卸载),全部满足才接管:多行模式;`key==='Enter'` 且无 `ctrl/meta/alt`;非输入法(`isComposing` / `keyCode 229`);目标在 `[data-composer-card]` 内且是 `[data-composer-input][contenteditable="true"]`;`data-phase === 'plain'`;覆盖层内无 `[role="listbox"]`。
|
|
142
|
+
|
|
143
|
+
接管:`preventDefault + stopPropagation`,向编辑器派发翻转 `shiftKey` 的合成 `KeyboardEvent`(打标防递归):
|
|
144
|
+
|
|
145
|
+
- Enter → 派发 Shift+Enter:走 DSH 自己的换行分支(LineBreak,进 undo);
|
|
146
|
+
- Shift+Enter → 派发 Enter:走 DSH 自己的提交分支(busy-enter / canSubmit / 菜单仲裁全部保留)。
|
|
147
|
+
|
|
148
|
+
兜底(实机若合成事件不生效):改用 `inputActions.captureInsertion()` + `insertText('\n', span)` 换行、`inputActions.submit()` 提交,判定条件不变。
|
|
149
|
+
|
|
150
|
+
## 5. 边界与失败模式
|
|
151
|
+
|
|
152
|
+
| 场景 | 处理 |
|
|
153
|
+
|---|---|
|
|
154
|
+
| 输入法候选态 Enter | 放行 |
|
|
155
|
+
| `/`、`@` 菜单打开 | 检测 `[role=listbox]`,放行 |
|
|
156
|
+
| 命令 claim / 提交中 | `data-phase !== 'plain'` 放行 |
|
|
157
|
+
| Cmd/Ctrl+Enter、Alt+Enter | 放行 |
|
|
158
|
+
| 其它输入框里的 Ctrl+E | resolve 返回 pass |
|
|
159
|
+
| 设置弹窗/录制中 | `modal !== null` → pass |
|
|
160
|
+
| 快捷键冲突 | catch 降级,提示仍可点 |
|
|
161
|
+
| 快捷键被清空 | 提示只显示文案 |
|
|
162
|
+
| 无 shortcuts 服务 | `ctx.inject` 不触发,其余功能照常 |
|
|
163
|
+
| localStorage 不可用 | 内存态 |
|
|
164
|
+
| 两个 composer | 高度统一生效,提示各自渲染,监听一份 |
|
|
165
|
+
| 只读 composer | `contenteditable="true"` 才接管 |
|
|
166
|
+
| DSH DOM 锚点变化 | 首次挂载自检,缺锚点只 warn 一次,不崩 |
|
|
167
|
+
| 卸载 | 移除 style / 属性 / 监听 / 命令 |
|
|
168
|
+
|
|
169
|
+
## 6. 测试与验收
|
|
170
|
+
|
|
171
|
+
**自动**(`npm test` = `node --test`):harness 垫 `window.__ModuleLoader__` 与 `require('react')`,取 `client.js` 的 `__test` 纯函数,覆盖:模式循环与非法值归一;存储异常;`formatShortcut`;默认组合常量(桌面 control+KeyE、web primary+shift+KeyE、web:linux 无);`shouldIntercept` 全分支(多行/单行、Shift、修饰键、IME、phase、菜单、目标);属性 set/remove。
|
|
172
|
+
**静态**:`node --check`、locale JSON 解析、`npm --cache .npm-cache pack --dry-run` 文件清单。
|
|
173
|
+
**实机**(安装后逐条):提示出现/隐藏;切换高度 4 行;Enter 换行、Shift+Enter 发送;Cmd/Ctrl+Enter 不变;斜杠菜单 Enter 不变;输入法不误触;刷新保持;设置改键生效;禁用后恢复原样。
|
|
174
|
+
|
|
175
|
+
## 7. 安装与验证
|
|
176
|
+
|
|
177
|
+
- 先重新读一遍第 2 节源码文件,确认 master 未漂移。
|
|
178
|
+
- 装进正在使用的 profile(当前 `main`):优先应用内「插件」页 → 添加插件 → 绝对路径 `/Users/zilong/zWork/ai/zl-plugins/dsh-ml-input` → 安装 → 启用 → 刷新;备选 `dsh plugin --profile main add link:/Users/zilong/zWork/ai/zl-plugins/dsh-ml-input`(执行前单独确认)。
|
|
179
|
+
- 之后改 `client.js`/CSS 刷新页面即可;清单变更才需重启。
|
|
180
|
+
- 回滚:插件页关闭/卸载,或 `dsh plugin --profile main remove dsh-ml-input`。
|
|
181
|
+
|
|
182
|
+
## 8. 文档与存档(按既有规则)
|
|
183
|
+
|
|
184
|
+
- 获批后把本方案原文存到 `docs/PLAN-2026-10-27.md`,实施后追加「实施偏差(事后补记)」。
|
|
185
|
+
- 新建 `docs/RELEASE-CHECKLIST-0.1.0.md`;README 加文档索引,ROADMAP 挂链接;CHANGELOG 记 0.1.0。
|
|
186
|
+
- README 写明源码依据文件与 commit,便于 DSH 升级后回归。
|
|
187
|
+
|
|
188
|
+
## 9. 不做(v0.1.0)
|
|
189
|
+
|
|
190
|
+
不改 DSH 源码;不做内联录制器(用官方设置页);不做 Host 路由/配置页;不做按会话记忆;不发 npm、不做市场条目、不建远端;不处理 TUI/CLI。
|
|
191
|
+
|
|
192
|
+
## 10. 假设与风险
|
|
193
|
+
|
|
194
|
+
- 合成键盘事件能触发 Lexical 根监听(源码已确认监听在编辑根节点、Enter handler 只看 `shiftKey`);不成立则走 `InputActions` 兜底。
|
|
195
|
+
- 依赖的 `data-*` 锚点与 `role=listbox` 属内部实现,已在 rc.3 源码确认;升级 DSH 需回归,插件做缺锚点 warn。
|
|
196
|
+
- `Ctrl+E` 默认组合与已装插件不冲突(已查);运行期冲突仍由 try/catch 兜底。
|
|
197
|
+
- `1lh` 需要较新 Chromium;桌面版满足,且保留固定 px 回退。
|
|
198
|
+
- 我无法直接操作你屏幕上的 Electron 窗口,UI 观感与交互需你按第 6 节确认,我按反馈迭代。
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 实施偏差(事后补记)
|
|
203
|
+
|
|
204
|
+
> 本节为实施后补记,与上方计划原文分开看。
|
|
205
|
+
|
|
206
|
+
1. **目标 profile 判断错误**:计划里写「当前 `main`」,实际当前运行的是 **DeepSeek Harness.app + `desktop` profile**。依据:运行中的 Host 进程 cwd 与打开的 `~/.dsh/profiles/desktop/package.json`、`cordis.patch.yml`;本会话可用的 zotero / im / univer 工具只出现在 desktop 的 bundle 列表;desktop profile 的 `.plugin-manager` 日志显示最近的本地 link 安装。`main` 属于同时运行的 DSH NEXT(监听 58501,日志 `desktop-next.log`),与当前 GUI 不是同一实例。
|
|
207
|
+
2. **安装方式**:`dsh plugin --profile desktop add ...` 被应用独占拒绝(`profile "desktop" is managed exclusively by the Electron application`)。改为等价的手动 link:用应用自带的 pnpm 11.28.5 在 desktop profile 执行 `pnpm add link:/Users/zilong/zWork/ai/zl-plugins/dsh-ml-input`,并把 `dsh-ml-input` 追加进 `dsh.profile.bundles`。profile 的 `patchReload: live` 让 HMR 监听 `package.json`,改动后自动 reconcile。
|
|
208
|
+
3. **Host 行激活验证**:曾临时在 `index.js` 的 `apply()` 里写文件哨兵,并在 profile 补丁里临时加 `- id: ml-input` 的 disable/enable 行强制重载;哨兵于 `2026-10-27T16:30:37Z` 出现,证明 Host 已组合并激活插件行。随后已删除哨兵、恢复补丁文件并还原 `index.js`。
|
|
209
|
+
4. **浏览器半验证**:曾临时在 `apply()` 写 `localStorage['dsh-ml-input.selfcheck']`,在 Electron 的 LevelDB 中读到该键,证明客户端 bundle 已加载且 `apply()` 真正执行;随后用一次性 removeItem 版本清除并恢复最终代码。
|
|
210
|
+
5. 实现与计划一致:单文件手写 `client.js`、`conversation.input.overlay` 提示、`ctx.shortcuts` 命令、捕获阶段翻转 `shiftKey` 的 Enter 接管、`data-*` 锚点。(计划里“用 `useInput` 判断空草稿”在后续样式调整中已移除,提示改为常驻。)
|
|
211
|
+
10. **改名(2026-10-28)**:开源前把包名从 `dsh-ml-input` 改为 **`dsh-multiline-input`**(`ml` 在 LLM 插件市场易被读成 machine learning;新名与 `dsh-composer-*` 命名簇一致、可搜索)。同步改了模块 id、locale NS、快捷键命令 id(`multiline-input.toggle`)、storage key、宿主 row id、CSS 前缀(`dml-`→`dmi-`)、profile 依赖与 bundle 列表;本地目录名保持 `dsh-ml-input`(会话工作区不便改名,包名以 package.json 为准)。
|
|
212
|
+
6. **开发热更新更正**:计划第 7 节写「改 client.js 刷新页面即可」,实测不成立——HMR 默认忽略 `**/node_modules`(`packages/boot/hmr/src/index.ts`),链接安装的 `client.js` 改动不会被 `rebuilt()` 拾取。实际做法:在插件页把本插件关/开一次(或重启应用)触发客户端 bundle 重组,再刷新页面;`package.json` / 补丁文件的改动仍会被 profile 监听自动 reconcile。README 已同步。
|
|
213
|
+
7. **验收后的用户调整(2026-10-28)**:按子龙反馈——① 桌面默认键从字面 `Control+E` 改为 `primary+KeyE`(macOS 显示并生效为 `Cmd+E`,Windows/Linux 为 `Ctrl+E`);② 提示改为**常驻**,不再随输入隐藏。
|
|
214
|
+
8. **提示样式再调整(2026-10-28)**:试过“与卡片一体的顶部窄条”(同底色、底部淡线、给卡片预留 `padding-top`),但子龙要的是**贴着输入框上沿、像给输入框加一个 tab** 的形态。
|
|
215
|
+
9. **最终形态(2026-10-28)**:提示改为输入框上沿右侧的小 tab——`position:absolute; right: calc(var(--dsw-radius-panel) + 4px); bottom: 100%`(浮在卡片上方、不占输入区布局)、背景 `--dsw-specific-input-major`(同卡片底色)、宽度 `max-content`(只包文字)、顶部 `--dsw-radius-md` 圆角、底部 `0.5px solid --dsw-alias-border-l2`(与卡片描边同色,视觉上连成一体);原先的卡片 `padding-top` 预留已移除。
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# dsh-multiline-input 0.1.0 发版 / 执行检查清单
|
|
2
|
+
|
|
3
|
+
> 每次发版或装到本机验收时逐条勾选;未勾选的项目要写明原因。
|
|
4
|
+
|
|
5
|
+
## 1. 代码与静态检查
|
|
6
|
+
|
|
7
|
+
- [x] `node --check client.js`
|
|
8
|
+
- [x] `node --check index.js`
|
|
9
|
+
- [x] `npm test`(`node --test`)全绿
|
|
10
|
+
- [x] `locale/zh.json`、`locale/en.json`、`package.json` 可被 JSON 解析
|
|
11
|
+
- [x] `npm --cache .npm-cache pack --dry-run` 文件清单包含 `client.js`、`cordis.patch.yml`、`locale/*`、README、docs
|
|
12
|
+
- [x] `package.json` 版本 = `0.1.0`,`dsh.compatibility.dsh` = `>=0.2.1-rc.3 <0.3.0-0`
|
|
13
|
+
|
|
14
|
+
## 2. 安装
|
|
15
|
+
|
|
16
|
+
> 本机运行中的 DeepSeek Harness 使用 `desktop` profile,由应用独占;`dsh plugin --profile desktop` 会报
|
|
17
|
+
> `profile "desktop" is managed exclusively by the Electron application`。
|
|
18
|
+
|
|
19
|
+
- [x] 方式一(应用内):「插件」页 → 添加插件 → 绝对路径本目录 → 安装 → 启用
|
|
20
|
+
- [x] 方式二(开发期手动 link,已在 2026-10-28 执行):
|
|
21
|
+
`cd ~/.dsh/profiles/desktop && pnpm add link:<本目录>`,
|
|
22
|
+
再把 `dsh-multiline-input` 追加进 `package.json` 的 `dsh.profile.bundles`;
|
|
23
|
+
profile 的 `patchReload: live` 会自动 reconcile(曾用临时文件哨兵验证 Host 行于 2026-10-27T16:30:37Z 激活)
|
|
24
|
+
- [ ] 插件出现在已安装列表,且能启用 / 禁用
|
|
25
|
+
- [ ] 刷新页面后插件加载(无控制台报错;若报错记录完整堆栈)
|
|
26
|
+
|
|
27
|
+
## 3. 实机验收(空会话 + 有内容会话各一次)
|
|
28
|
+
|
|
29
|
+
- [ ] 输入框上方右侧常驻显示 `Cmd+E 多行输入`(macOS;Windows / Linux 为 `Ctrl+E`)
|
|
30
|
+
- [ ] 输入文字后提示仍在(常驻,不隐藏)
|
|
31
|
+
- [ ] `Cmd+E` / `Ctrl+E` 在编辑器内可切换;在页面空白处也可切换
|
|
32
|
+
- [ ] 多行模式输入框最小高度 4 行;切回单行恢复原高度
|
|
33
|
+
- [ ] 多行模式 `Enter` 换行(`Cmd/Ctrl+Z` 可撤销)
|
|
34
|
+
- [ ] 多行模式 `Shift+Enter` 发送
|
|
35
|
+
- [ ] 多行模式 `Cmd/Ctrl+Enter` 仍为加速提交
|
|
36
|
+
- [ ] 输入 `/` 打开菜单后 `Enter` 仍选中菜单项,不换行
|
|
37
|
+
- [ ] 输入 `@` 打开引用菜单后 `Enter` 仍选中菜单项
|
|
38
|
+
- [ ] 中文输入法候选态按 `Enter` 不误触,选词正常
|
|
39
|
+
- [ ] 在设置搜索框 / 会话重命名输入框内按 `Ctrl+E` 不切换模式
|
|
40
|
+
- [ ] 刷新页面后模式保持;重启应用后模式保持
|
|
41
|
+
- [ ] `设置 → 通用 → 快捷键` 中出现「切换多行输入」,可改绑、可恢复默认
|
|
42
|
+
- [ ] 改绑后右上角提示立即更新;清空绑定后提示只显示文案且可点击
|
|
43
|
+
- [ ] 禁用插件后:提示消失、高度恢复、`Ctrl+E` 不再切换、无残留 DOM / 控制台报错
|
|
44
|
+
|
|
45
|
+
## 4. 回滚
|
|
46
|
+
|
|
47
|
+
- [ ] 插件页关闭或卸载;或 `dsh plugin --profile <profile> remove dsh-multiline-input`
|
|
48
|
+
- [ ] 回滚后与安装前一致(原输入框行为、快捷键设置里的条目消失)
|
|
49
|
+
|
|
50
|
+
## 5. 存档
|
|
51
|
+
|
|
52
|
+
- [ ] `docs/PLAN-2026-10-27.md` 已保存获批原文
|
|
53
|
+
- [ ] 实施偏差已回填到计划文末
|
|
54
|
+
- [ ] README 文档索引、ROADMAP 链接已更新
|
|
55
|
+
- [ ] git 首次提交完成(本地)
|
|
56
|
+
|
|
57
|
+
## 备注
|
|
58
|
+
|
|
59
|
+
- 本版本不发布 npm、不做市场条目、不建远端。
|
|
60
|
+
- 依赖 DSH `0.2.1-rc.3` 的 composer DOM 锚点;升级 DSH 后按第 3 节重新验收。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
2
|
+
<rect x="3" y="4" width="18" height="16" rx="4" stroke="currentColor" stroke-width="1.6"/>
|
|
3
|
+
<path d="M7 9h10M7 12h10M7 15h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
4
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-multiline-input.
|
|
3
|
+
*
|
|
4
|
+
* The feature is browser-only. This row exists so the package is a real
|
|
5
|
+
* bundle row that the Plugins page can install, enable and disable; the
|
|
6
|
+
* browser half is discovered through package.json's `dsh.client`
|
|
7
|
+
* declaration and served by client-modules.
|
|
8
|
+
*
|
|
9
|
+
* Reference contracts (source checkout `deepseek-harness`, 0.2.1-rc.3):
|
|
10
|
+
* - packages/client/modules/README.zh.md
|
|
11
|
+
* - packages/util/package-manifest/src/types.ts (DshClientManifest)
|
|
12
|
+
* - packages/boot/plugin-manager/src/index.ts ("not-bundle")
|
|
13
|
+
*/
|
|
14
|
+
export const name = 'multiline-input'
|
|
15
|
+
|
|
16
|
+
/** The Host half owns no services; it only keeps the bundle row alive. */
|
|
17
|
+
export function apply() {}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Multiline input",
|
|
4
|
+
"description": "Toggle the conversation composer between single-line and multi-line: Ctrl+E by default (editable in Settings → General → Keyboard shortcuts), Enter inserts a newline and Shift+Enter sends while multi-line."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multiline-input",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Single/multi-line composer toggle for DeepSeek Harness: an editable Cmd/Ctrl+E shortcut, a tab hint on the composer, a 4-line floor for multi-line drafts, and Shift+Enter to send.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ZilongYang",
|
|
8
|
+
"icon": "./icon.svg",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"deepseek",
|
|
11
|
+
"deepseek-harness",
|
|
12
|
+
"dsh",
|
|
13
|
+
"dsh-plugin",
|
|
14
|
+
"client-plugin",
|
|
15
|
+
"composer",
|
|
16
|
+
"multiline",
|
|
17
|
+
"input",
|
|
18
|
+
"keyboard-shortcut",
|
|
19
|
+
"web"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.3"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.js",
|
|
29
|
+
"./client": "./client.js",
|
|
30
|
+
"./package.json": "./package.json",
|
|
31
|
+
"./locale/*.json": "./locale/*.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"index.js",
|
|
35
|
+
"client.js",
|
|
36
|
+
"cordis.patch.yml",
|
|
37
|
+
"icon.svg",
|
|
38
|
+
"locale/zh.json",
|
|
39
|
+
"locale/en.json",
|
|
40
|
+
"screenshots.json",
|
|
41
|
+
"assets",
|
|
42
|
+
"README.md",
|
|
43
|
+
"ROADMAP.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"docs"
|
|
47
|
+
],
|
|
48
|
+
"dsh": {
|
|
49
|
+
"bundle": {
|
|
50
|
+
"patch": "./cordis.patch.yml"
|
|
51
|
+
},
|
|
52
|
+
"client": {
|
|
53
|
+
"platform": "web",
|
|
54
|
+
"immediately": true,
|
|
55
|
+
"inject": [
|
|
56
|
+
"@deepseek-ai/dsh-client-locale",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"compatibility": {
|
|
61
|
+
"dsh": ">=0.2.1-rc.3 <0.3.0-0"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/ZilongYang/dsh-multiline-input.git"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/ZilongYang/dsh-multiline-input#readme",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/ZilongYang/dsh-multiline-input/issues"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/screenshots.json
ADDED