@huanlin/dsh-plugin-input-history 0.1.2 → 0.2.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/README.md +30 -30
- package/lib/client.js +202 -165
- package/lib/client.js.map +1 -1
- package/lib/types/client/HistoryDock.d.ts +27 -15
- package/lib/types/client/HistoryDock.js +136 -32
- package/lib/types/client/dom.d.ts +63 -39
- package/lib/types/client/dom.js +121 -78
- package/lib/types/client/index.d.ts +14 -22
- package/lib/types/client/index.js +16 -124
- package/lib/types/index.d.ts +6 -7
- package/lib/types/index.js +6 -7
- package/package.json +16 -19
- package/lib/invariant.js +0 -24
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -26
package/README.md
CHANGED
|
@@ -12,69 +12,69 @@
|
|
|
12
12
|
|
|
13
13
|
## 功能
|
|
14
14
|
|
|
15
|
-
- **↑ / ↓ 切换历史**:在 DSH prompt
|
|
15
|
+
- **↑ / ↓ 切换历史**:在 DSH prompt 输入框(聊天页底部的 Lexical 编辑器)按方向键,从最近一条 prompt 开始向旧回溯,或向新前进。
|
|
16
16
|
- **草稿保留**:切到历史预览后,按 ↓ 越过最新条目会自动恢复用户原本正在编辑的草稿——不会丢失在途文本。
|
|
17
17
|
- **跨会话全局持久化**:历史来自所有会话的 `user` + `steering` 消息,存到 `localStorage`(FIFO,500 条上限),刷新浏览器、新建会话、切换 workspace 都保留。
|
|
18
18
|
- **多行边界触发**:ArrowUp 仅在光标位于第一行任意位置时触发;ArrowDown 仅在最后一行任意位置时触发。多行编辑时方向键仍正常移动光标,不会被劫持。
|
|
19
19
|
- **IME 安全**:中文/日文输入法候选词状态按方向键不会被劫持(遵循 DSH core InputBar 的 IME 守卫约定,issue #535)。
|
|
20
|
-
-
|
|
21
|
-
- **零源码 patch**:纯插件,通过 `conversation.composer.dock`
|
|
20
|
+
- **斜杠菜单兼容**:斜杠命令 / @ 引用菜单打开时,方向键归菜单高亮导航使用,插件不动。
|
|
21
|
+
- **零源码 patch**:纯插件,通过 `conversation.composer.dock` 隐藏条目挂载捕获阶段 `keydown` 监听器。不修改 DSH 源码任何文件。
|
|
22
22
|
|
|
23
23
|
## 架构
|
|
24
24
|
|
|
25
|
-
单 bundle 双入口(host `.` + 浏览器 `./client
|
|
25
|
+
单 bundle 双入口(host `.` + 浏览器 `./client`),仿照 `dsh-spur` / `dsh-auto-blame`。不发布 `./invariant` 伴生入口(rc.1 起上游禁止空 invariant installer:本插件的 slot 注册由 HMR 安全 spec 覆盖、localStorage 写入有 try/catch 包裹,均无独立可分歧的观察,故按新规省略其源码与装配)。
|
|
26
26
|
|
|
27
27
|
- **宿主半边**(`src/index.ts`):空 `apply`——纯客户端插件。
|
|
28
|
-
- **浏览器半边**(`src/client/index.ts
|
|
29
|
-
- 注册 `conversation.composer.dock` list slot(id `dsh-plugin-input-history`,order 100)。dock 条目渲染一个 `display: none` 的不可见 anchor,仅负责**历史收集**(每次 render 读 `session.nodes`,新 user/steering 文本 append 到 store)。
|
|
30
|
-
- 在 `apply` 里直接挂 `document.addEventListener('keydown', ...)` bubble-phase 监听器,负责**历史导航**。监听器放在 `apply` 而非 dock 组件里,因为 dock 是 session scope,而 DSH 把 blank session 当作 hero 渲染(`ConversationRoot.tsx:79-80`),hero 模式下 dock 不挂载(`ConversationRoot.tsx:156` 的 `!hero` 守卫)。放在 `apply` 确保健听器始终在线。
|
|
28
|
+
- **浏览器半边**(`src/client/index.ts`):注册 `conversation.composer.dock` list slot(id `dsh-plugin-input-history`,order 100,与 ui-chat 的 StatsLine 同槽共存)。dock 条目渲染一个 `display: none` 的不可见 anchor,**历史收集**与**历史导航**都由这个 session 作用域的 dock 组件承担——两者都需要只有 session 作用域 slot 组件才能拿到的机器接口(`useChat` / `inputActions`)。
|
|
31
29
|
- **纯函数模块**:
|
|
32
30
|
- `src/client/history.ts`:`appendHistory` / `nextIndex` / `entryAt` / `HistoryStore`(localStorage 后端,quota 异常降级为内存)
|
|
33
|
-
- `src/client/dom.ts`:`
|
|
31
|
+
- `src/client/dom.ts`:`findComposerEditable` / `findTriggerMenu`(data-attribute 定位器)/ `caretLineBoundary`(光标-行盒几何)/ `boundaryFromLineTops`(纯判定核心)
|
|
34
32
|
- `src/client/ime.ts`:`isImeComposition`(IME 守卫)
|
|
35
33
|
|
|
36
|
-
###
|
|
34
|
+
### 历史回填机制:`inputActions.setDraft`
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
v0.1.2 起 composer 是 Lexical contenteditable,没有 textarea 可以用 native setter 驱动。插件改走 slot 系统注入的 `inputActions.setDraft(text)`——这是 input machine 的公开整草稿写入口(Lexical update、光标落到末尾、undo 合并)。注意:含引用 chip 的草稿恢复时按 clipboard 投影文本还原(chip 变回 `/name` 形式)。
|
|
39
37
|
|
|
40
38
|
### Slot 选择
|
|
41
39
|
|
|
42
|
-
`conversation.composer.dock`(list,session
|
|
40
|
+
`conversation.composer.dock`(list,session 作用域)——composer 卡片下方的条带,由 `ui-conversation` 拥有。**rc.1 起该 slot 不再有 `InputZone` owner props**(`input` 不随 props 下发),dock 条目从框架接收 `SessionStandardProps`(`sessionId` / `useInput` / `inputActions`,以及 ui-chat merge 进来的 `useChat`),`input` 改经 `useInput(s => s)` 读取。DSH 把 blank session 当作 hero 渲染时该 dock 不挂载,插件随之休眠——hero 模式本就没有 input machine,导航无处落地(见已知限制)。
|
|
43
41
|
|
|
44
42
|
### 历史收集
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
dock 通过 `useChat(s => s.legacy.nodes)` 订阅 Chat target 的节点列表(`ConversationNode` 平铺数组,新在后),从尾向前找到第一个 `kind === 'user'` 或 `kind === 'steering'` 节点,提取其 `content` 中所有 `type === 'text'` 块的文本拼接。文本变化时 append 到 `HistoryStore`。store 内部做去重(最新相等 no-op、旧出现移到末尾)和 FIFO 截断,重复 append 无副作用。
|
|
47
45
|
|
|
48
46
|
### 键盘事件处理链
|
|
49
47
|
|
|
50
|
-
`document.addEventListener('keydown', handler,
|
|
48
|
+
`document.addEventListener('keydown', handler, true)`(**捕获阶段**,在 Lexical 挂在 editable 元素上的 keydown 监听器之前触发),监听器由 dock 组件挂载/卸载:
|
|
51
49
|
|
|
52
50
|
1. **键过滤**:只处理 `ArrowUp` / `ArrowDown`。
|
|
53
51
|
2. **IME 守卫**:`event.isComposing || event.keyCode === 229` → 放行。
|
|
54
|
-
3.
|
|
55
|
-
4. **
|
|
56
|
-
5.
|
|
57
|
-
6. **
|
|
58
|
-
7. **多行边界**:`
|
|
59
|
-
8. **导航**:`nextIndex(cursor, total, dir)` 计算下一索引;`null` 表示越过最新端 →
|
|
60
|
-
9. **草稿保存**:第一次从"未导航"切到"导航中"时,把当前 `
|
|
61
|
-
|
|
62
|
-
### 历史回填:native setter
|
|
63
|
-
|
|
64
|
-
`setNativeTextareaValue(textarea, value)` 用 `Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set` 调用原生 setter(绕过 React 的 value tracker),然后 `dispatchEvent(new Event('input', { bubbles: true }))`。React 感知到值变化,触发 InputBar 的 `onChange` → `keyboard.setDraft(next)` → input machine 更新 draft → React 重新渲染 textarea value。与用户手动输入走完全相同的路径。
|
|
52
|
+
3. **已拦截守卫**:`event.defaultPrevented` → 放行。
|
|
53
|
+
4. **editable 定位**:`findComposerEditable(event.target)` 向上找 `[data-composer-card]` 祖先,查其下 `[data-composer-input]`(Lexical contenteditable),并要求 target 在 editable 内部(卡片按钮/chrome 上的按键不触发)。
|
|
54
|
+
5. **菜单兼容**:`findTriggerMenu(editable)` 在同一卡片内查 `[data-trigger-menu]`——菜单打开时方向键归菜单高亮仲裁(捕获阶段先于 keymap 的 arbitrate,不能再用 `defaultPrevented` 启发式)。
|
|
55
|
+
6. **phase gate**:`input.phase !== 'plain'` 放行(adjudicating / claimed / submitting 提交事务中不干扰)。
|
|
56
|
+
7. **多行边界**:`caretLineBoundary(editable)` 用折叠选区的 rect 与内容行盒 tops 比较;ArrowUp 仅 `atFirstLine` 触发,ArrowDown 仅 `atLastLine` 触发;几何不可得(无布局环境)→ 不导航。
|
|
57
|
+
8. **导航**:`nextIndex(cursor, total, dir)` 计算下一索引;`null` 表示越过最新端 → `inputActions.setDraft(savedDraft)` 恢复草稿;否则 `setDraft(entry)`。
|
|
58
|
+
9. **草稿保存**:第一次从"未导航"切到"导航中"时,把当前 `input.draft`(clipboard 投影)存到 `savedDraft`。
|
|
59
|
+
10. **消费事件**:`preventDefault()` + `stopPropagation()`。捕获阶段在 document 上 `stopPropagation` 使事件永远到不了 Lexical 的 editable 监听器——否则 keymap 会在草稿已被替换后再移动光标。
|
|
65
60
|
|
|
66
61
|
## 开发
|
|
67
62
|
|
|
68
63
|
```sh
|
|
69
64
|
pnpm install # 安装开发依赖
|
|
70
|
-
pnpm run typecheck # tsc --noEmit(通过
|
|
65
|
+
pnpm run typecheck # tsc --noEmit(通过 tsconfig paths + node_modules junction 解析 DSH 源码)
|
|
71
66
|
pnpm test # vitest run(纯函数单元测试)
|
|
72
|
-
pnpm run build #
|
|
67
|
+
pnpm run build # tsdown + tsc → lib/index.js, lib/client.js, lib/types/
|
|
73
68
|
```
|
|
74
69
|
|
|
75
70
|
### 基于 DSH checkout 类型检查
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
`@deepseek-ai/*` 的 alpha 版本未发布到 npm:`peerDependencies` 照写 `^0.1.2-rc.1`(仅声明),本地开发用两条通道解析类型——
|
|
73
|
+
|
|
74
|
+
1. `tsconfig.json` 的 `paths` 指向 `C:/Users/Administrator/.dsh/source/current/packages/*/lib/types`(需该 checkout 已 `pnpm run build`);
|
|
75
|
+
2. `node_modules/@deepseek-ai/*` 为指向同一 checkout 的 junction(首次需手工创建,或从 dsh-interpreters / dsh-mineru 复制做法)。
|
|
76
|
+
|
|
77
|
+
`pnpm-workspace.yaml` 的 `autoInstallPeers: false` 阻止 pnpm 去 registry 拉取不存在的 peer 版本(pnpm 10 读 workspace.yaml 的这一项,不是 `.npmrc`)。
|
|
78
78
|
|
|
79
79
|
### 预构建 lib/
|
|
80
80
|
|
|
@@ -102,9 +102,9 @@ dsh plugin --profile web add link:D:/Projects/deepseek-harness/dsh-plugin-input-
|
|
|
102
102
|
## 已知限制
|
|
103
103
|
|
|
104
104
|
- **Capacity 硬编码。** 历史上限固定为 500 条(`DEFAULT_CAPACITY` in `src/client/history.ts`)。改为 `Config` 字段需要 host-client 间 RPC 通道(client bundle 与 host bundle 是独立的模块作用域,无法直接共享 Config)。如需调整,编辑源码后重建 `lib/`。
|
|
105
|
-
- **
|
|
106
|
-
-
|
|
107
|
-
-
|
|
105
|
+
- **DOM 标记是上游内部属性。** 插件通过 `[data-composer-card]` / `[data-composer-input]` / `[data-trigger-menu]` 定位 composer 卡片、Lexical editable 与触发菜单。这些属性是 `ui-conversation` / `ui-input-trigger` 包的内部实现,目前稳定但无文档保证;上游若改名,定位器需要更新(单点:`src/client/dom.ts`)。
|
|
106
|
+
- **hero / blank session 下休眠。** DSH 把 blank session 当作 hero 渲染,`conversation.composer.dock` 不挂载,插件的收集与监听都随 dock 休眠。且 hero 模式本就没有当前 session 的 input machine(`inputActions` 不存在),导航无处落地。会话激活后(发出第一条消息)恢复正常。
|
|
107
|
+
- **含 chip 的草稿按纯文本恢复。** 导航保存的"在途草稿"是 `input.draft`(clipboard 投影):恢复时引用 chip 会变回 `/name` 纯文本形式。历史条目本身也始终是纯文本。
|
|
108
108
|
- **无跨 tab 同步。** 多窗口同时发消息时 localStorage 写竞争通过 try/catch 容错;最坏丢一条,下次 append 会纠正。
|
|
109
109
|
- **历史范围全局共享。** 不区分 workspace / session,所有 `user` + `steering` 消息进同一历史。如需按 workspace 隔离,需要扩展 `HistoryStore` 的 key 命名空间。
|
|
110
110
|
|
package/lib/client.js
CHANGED
|
@@ -204,58 +204,116 @@ function safeLocalStorage() {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
//#endregion
|
|
207
|
-
//#region src/client/
|
|
207
|
+
//#region src/client/dom.ts
|
|
208
208
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
209
|
+
* Pure decision over caret geometry: where a caret resting at `caretTop`
|
|
210
|
+
* sits relative to the box whose visual line tops are `lineTops` (ascending,
|
|
211
|
+
* one entry per visual line, viewport coordinates).
|
|
212
|
+
*
|
|
213
|
+
* @param caretTop - viewport `top` of the collapsed caret's box.
|
|
214
|
+
* @param lineTops - viewport `top` of each visual line, ascending.
|
|
215
|
+
* @param tolerance - px slop absorbing subpixel rounding between the caret
|
|
216
|
+
* rect and its line's rect.
|
|
217
|
+
* @returns the boundary flags; an empty `lineTops` (empty editable) is
|
|
218
|
+
* treated as a single virtual line, so both flags are true.
|
|
211
219
|
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
function boundaryFromLineTops(caretTop, lineTops, tolerance) {
|
|
221
|
+
if (lineTops.length === 0) return {
|
|
222
|
+
atFirstLine: true,
|
|
223
|
+
atLastLine: true
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
atFirstLine: caretTop <= lineTops[0] + tolerance,
|
|
227
|
+
atLastLine: caretTop >= lineTops[lineTops.length - 1] - tolerance
|
|
228
|
+
};
|
|
217
229
|
}
|
|
218
230
|
/**
|
|
219
|
-
*
|
|
231
|
+
* Locate the DSH composer editable the event targeted.
|
|
220
232
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
233
|
+
* Walks from the event target up to the closest `[data-composer-card]`
|
|
234
|
+
* ancestor, queries the `[data-composer-input]` contenteditable inside it,
|
|
235
|
+
* and confirms the target sits inside that editable (keystrokes on the
|
|
236
|
+
* card's buttons and chrome do not navigate history). Returns `null` when
|
|
237
|
+
* the target is not inside the composer editable.
|
|
238
|
+
*
|
|
239
|
+
* @param from - the event target (or any node inside the composer editable).
|
|
240
|
+
* @returns the editable element, or `null` when not found.
|
|
223
241
|
*/
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
233
|
-
"aria-hidden": true,
|
|
234
|
-
style: { display: "none" },
|
|
235
|
-
"data-dsh-plugin-input-history": ""
|
|
236
|
-
});
|
|
242
|
+
function findComposerEditable(from) {
|
|
243
|
+
if (typeof document === "undefined") return null;
|
|
244
|
+
if (from === null || !(from instanceof Element)) return null;
|
|
245
|
+
const card = from.closest("[data-composer-card]");
|
|
246
|
+
if (card === null) return null;
|
|
247
|
+
const editable = card.querySelector("[data-composer-input]");
|
|
248
|
+
if (editable === null) return null;
|
|
249
|
+
return editable.contains(from) ? editable : null;
|
|
237
250
|
}
|
|
238
251
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
252
|
+
* Detect an open trigger (slash-command / @-mention) menu inside the
|
|
253
|
+
* composer card that owns `editable`.
|
|
241
254
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
255
|
+
* While the menu is open, ArrowUp/ArrowDown move the highlighted row and
|
|
256
|
+
* must not recall history. The menu renders inside the same
|
|
257
|
+
* `[data-composer-card]` as the editable and carries the stable
|
|
258
|
+
* `data-trigger-menu` marker.
|
|
245
259
|
*
|
|
246
|
-
* @param
|
|
260
|
+
* @param editable - the composer editable element.
|
|
261
|
+
* @returns the menu element, or `null` when no menu is open.
|
|
247
262
|
*/
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
function findTriggerMenu(editable) {
|
|
264
|
+
const card = editable.closest("[data-composer-card]");
|
|
265
|
+
return card === null ? null : card.querySelector("[data-trigger-menu]");
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Decide the collapsed caret's line boundary inside the composer editable.
|
|
269
|
+
*
|
|
270
|
+
* Compares the caret's viewport box against the editable content's visual
|
|
271
|
+
* line boxes (`Range.getClientRects()` yields one rect per line fragment;
|
|
272
|
+
* fragments of the same visual line share a top within subpixel slop, so
|
|
273
|
+
* tops are deduped with a 2px threshold). A non-collapsed selection and a
|
|
274
|
+
* geometry-less environment (headless/jsdom) both return `null`, which the
|
|
275
|
+
* caller must treat as "do not navigate".
|
|
276
|
+
*
|
|
277
|
+
* @param editable - the composer editable element.
|
|
278
|
+
* @param tolerance - px slop between the caret rect and its line rect
|
|
279
|
+
* (defaults to 4px).
|
|
280
|
+
* @returns the boundary flags, or `null` when they cannot be determined.
|
|
281
|
+
*/
|
|
282
|
+
function caretLineBoundary(editable, tolerance = 4) {
|
|
283
|
+
const selection = window.getSelection();
|
|
284
|
+
if (selection === null || selection.rangeCount === 0) return null;
|
|
285
|
+
if (!selection.isCollapsed) return null;
|
|
286
|
+
const caretTop = caretTopOf(selection);
|
|
287
|
+
if (caretTop === null) return null;
|
|
288
|
+
const lineTops = contentLineTops(editable);
|
|
289
|
+
if (lineTops === null) return null;
|
|
290
|
+
return boundaryFromLineTops(caretTop, lineTops, tolerance);
|
|
291
|
+
}
|
|
292
|
+
/** Viewport `top` of the collapsed caret's box, or `null` when unmeasurable. */
|
|
293
|
+
function caretTopOf(selection) {
|
|
294
|
+
const rects = selection.getRangeAt(0).getClientRects();
|
|
295
|
+
for (let i = 0; i < rects.length; i++) {
|
|
296
|
+
const rect = rects[i];
|
|
297
|
+
if (rect.height === 0 && rect.width === 0) continue;
|
|
298
|
+
return rect.top;
|
|
257
299
|
}
|
|
258
|
-
|
|
300
|
+
const anchor = selection.anchorNode;
|
|
301
|
+
const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement;
|
|
302
|
+
return el === void 0 || el === null ? null : el.getBoundingClientRect().top;
|
|
303
|
+
}
|
|
304
|
+
/** Ascending, deduped tops of the editable content's visual lines; `null` without geometry. Empty for an empty editable. */
|
|
305
|
+
function contentLineTops(editable) {
|
|
306
|
+
const range = document.createRange();
|
|
307
|
+
range.selectNodeContents(editable);
|
|
308
|
+
const rects = range.getClientRects();
|
|
309
|
+
const tops = [];
|
|
310
|
+
for (let i = 0; i < rects.length; i++) {
|
|
311
|
+
const rect = rects[i];
|
|
312
|
+
if (rect.height === 0 && rect.width === 0) continue;
|
|
313
|
+
const top = rect.top;
|
|
314
|
+
if (tops.length === 0 || Math.abs(top - tops[tops.length - 1]) > 2) tops.push(top);
|
|
315
|
+
}
|
|
316
|
+
return tops;
|
|
259
317
|
}
|
|
260
318
|
|
|
261
319
|
//#endregion
|
|
@@ -284,68 +342,118 @@ function isImeComposition(event) {
|
|
|
284
342
|
}
|
|
285
343
|
|
|
286
344
|
//#endregion
|
|
287
|
-
//#region src/client/
|
|
345
|
+
//#region src/client/HistoryDock.tsx
|
|
288
346
|
/**
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
347
|
+
* Module-scope history store, initialized once on first dock mount.
|
|
348
|
+
* Shared across dock mount/unmount cycles; the underlying data persists
|
|
349
|
+
* in `localStorage`.
|
|
350
|
+
*/
|
|
351
|
+
let historyStore = null;
|
|
352
|
+
/** Get the shared history store (initializes lazily on first call). */
|
|
353
|
+
function getHistoryStore() {
|
|
354
|
+
if (historyStore === null) historyStore = new HistoryStore(DEFAULT_CAPACITY);
|
|
355
|
+
return historyStore;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Render the invisible history dock entry: collection + navigation.
|
|
295
359
|
*
|
|
296
|
-
* @param
|
|
297
|
-
* @
|
|
298
|
-
* @param selectionEnd - the textarea's `selectionEnd` (defaults to `selectionStart`).
|
|
299
|
-
* @returns the caret's line information.
|
|
360
|
+
* @param props - dock runtime share (standard hooks) + locale seat.
|
|
361
|
+
* @returns an `aria-hidden` anchor with zero layout footprint.
|
|
300
362
|
*/
|
|
301
|
-
function
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
currentLine = i;
|
|
316
|
-
break;
|
|
317
|
-
}
|
|
318
|
-
runningLength = lineEnd + 1;
|
|
363
|
+
function HistoryDock({ useInput, useChat, inputActions, sessionId }) {
|
|
364
|
+
const input = useInput((s) => s);
|
|
365
|
+
const nodes = useChat((s) => s.legacy.nodes);
|
|
366
|
+
const lastText = (0, react.useMemo)(() => latestUserOrSteeringText(nodes), [nodes]);
|
|
367
|
+
(0, react.useEffect)(() => {
|
|
368
|
+
if (lastText !== null) getHistoryStore().append(lastText);
|
|
369
|
+
}, [lastText]);
|
|
370
|
+
const navCursorRef = (0, react.useRef)(null);
|
|
371
|
+
const savedDraftRef = (0, react.useRef)(null);
|
|
372
|
+
const prevSessionRef = (0, react.useRef)(sessionId);
|
|
373
|
+
if (prevSessionRef.current !== sessionId) {
|
|
374
|
+
prevSessionRef.current = sessionId;
|
|
375
|
+
navCursorRef.current = null;
|
|
376
|
+
savedDraftRef.current = null;
|
|
319
377
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
378
|
+
const inputRef = (0, react.useRef)(input);
|
|
379
|
+
inputRef.current = input;
|
|
380
|
+
const actionsRef = (0, react.useRef)(inputActions);
|
|
381
|
+
actionsRef.current = inputActions;
|
|
382
|
+
(0, react.useEffect)(() => {
|
|
383
|
+
if (typeof document === "undefined") return void 0;
|
|
384
|
+
const handler = (event) => {
|
|
385
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
386
|
+
if (isImeComposition(event)) return;
|
|
387
|
+
if (event.defaultPrevented) return;
|
|
388
|
+
if (actionsRef.current === void 0 || inputRef.current === void 0) return;
|
|
389
|
+
const editable = findComposerEditable(event.target);
|
|
390
|
+
if (editable === null) return;
|
|
391
|
+
if (findTriggerMenu(editable) !== null) return;
|
|
392
|
+
if (inputRef.current.phase !== "plain") return;
|
|
393
|
+
const boundary = caretLineBoundary(editable);
|
|
394
|
+
if (boundary === null) return;
|
|
395
|
+
if (event.key === "ArrowUp" && !boundary.atFirstLine) return;
|
|
396
|
+
if (event.key === "ArrowDown" && !boundary.atLastLine) return;
|
|
397
|
+
const history = getHistoryStore().list;
|
|
398
|
+
const dir = event.key === "ArrowUp" ? "up" : "down";
|
|
399
|
+
const next = nextIndex(navCursorRef.current, history.length, dir);
|
|
400
|
+
if (next === null) {
|
|
401
|
+
const saved = savedDraftRef.current;
|
|
402
|
+
navCursorRef.current = null;
|
|
403
|
+
if (saved !== null) {
|
|
404
|
+
actionsRef.current.setDraft(saved);
|
|
405
|
+
savedDraftRef.current = null;
|
|
406
|
+
}
|
|
407
|
+
consume(event);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (navCursorRef.current === null && savedDraftRef.current === null) savedDraftRef.current = inputRef.current.draft;
|
|
411
|
+
const entry = entryAt(history, next);
|
|
412
|
+
if (entry === null) return;
|
|
413
|
+
navCursorRef.current = next;
|
|
414
|
+
actionsRef.current.setDraft(entry);
|
|
415
|
+
consume(event);
|
|
416
|
+
};
|
|
417
|
+
document.addEventListener("keydown", handler, true);
|
|
418
|
+
return () => {
|
|
419
|
+
document.removeEventListener("keydown", handler, true);
|
|
420
|
+
};
|
|
421
|
+
}, []);
|
|
422
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
423
|
+
"aria-hidden": true,
|
|
424
|
+
style: { display: "none" },
|
|
425
|
+
"data-dsh-plugin-input-history": ""
|
|
426
|
+
});
|
|
326
427
|
}
|
|
327
428
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
|
|
333
|
-
|
|
429
|
+
* Consume a navigated keystroke: `preventDefault` stops the browser's own
|
|
430
|
+
* gesture, `stopPropagation` (capture phase, document level) keeps the
|
|
431
|
+
* event from ever reaching Lexical's editable keydown listener — otherwise
|
|
432
|
+
* the keymap would move the caret after the draft was already replaced.
|
|
433
|
+
*/
|
|
434
|
+
function consume(event) {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
event.stopPropagation();
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Extract the text of the latest `user` or `steering` node from the Chat
|
|
440
|
+
* target's legacy node list.
|
|
334
441
|
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
442
|
+
* Returns the concatenated text of all `type: 'text'` content blocks.
|
|
443
|
+
* Returns `null` when no user/steering node is present (e.g. a fresh
|
|
444
|
+
* session with only a system/context message).
|
|
337
445
|
*
|
|
338
|
-
* @param
|
|
339
|
-
* @returns the textarea element, or `null` when not found.
|
|
446
|
+
* @param nodes - the Chat snapshot's legacy `nodes` array (newest last).
|
|
340
447
|
*/
|
|
341
|
-
function
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
448
|
+
function latestUserOrSteeringText(nodes) {
|
|
449
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
450
|
+
const node = nodes[i];
|
|
451
|
+
if (node.kind !== "user" && node.kind !== "steering") continue;
|
|
452
|
+
const content = node.content;
|
|
453
|
+
if (content === void 0) continue;
|
|
454
|
+
let text = "";
|
|
455
|
+
for (const block of content) if (block.type === "text" && typeof block.text === "string") text += block.text;
|
|
456
|
+
return text;
|
|
349
457
|
}
|
|
350
458
|
return null;
|
|
351
459
|
}
|
|
@@ -495,14 +603,7 @@ const dicts = {
|
|
|
495
603
|
/** Required services: slots + locale. */
|
|
496
604
|
const inject = ["slots", "locale"];
|
|
497
605
|
/**
|
|
498
|
-
*
|
|
499
|
-
* because the listener is attached once in `apply` and must persist across
|
|
500
|
-
* dock mount/unmount cycles.
|
|
501
|
-
*/
|
|
502
|
-
let navCursor = null;
|
|
503
|
-
let savedDraft = null;
|
|
504
|
-
/**
|
|
505
|
-
* Client plugin body: register the dock + attach the keydown listener.
|
|
606
|
+
* Client plugin body: register the dock + locale dictionaries.
|
|
506
607
|
*
|
|
507
608
|
* @param ctx - client root context.
|
|
508
609
|
*/
|
|
@@ -532,70 +633,6 @@ function apply(ctx) {
|
|
|
532
633
|
order: 100,
|
|
533
634
|
locale: NS
|
|
534
635
|
}, HistoryDock));
|
|
535
|
-
ctx.effect(() => {
|
|
536
|
-
if (typeof document === "undefined") return () => {};
|
|
537
|
-
const handler = (event) => {
|
|
538
|
-
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
539
|
-
if (isImeComposition(event)) return;
|
|
540
|
-
if (event.defaultPrevented) return;
|
|
541
|
-
const textarea = findComposerTextarea(event.target);
|
|
542
|
-
if (textarea === null) return;
|
|
543
|
-
if (event.target !== textarea) return;
|
|
544
|
-
if (textarea.readOnly || textarea.disabled) return;
|
|
545
|
-
const history = getHistoryStore().list;
|
|
546
|
-
const info = cursorLineInfo(textarea.value, textarea.selectionStart, textarea.selectionEnd);
|
|
547
|
-
if (event.key === "ArrowUp" && !info.atFirstLine) return;
|
|
548
|
-
if (event.key === "ArrowDown" && !info.atLastLine) return;
|
|
549
|
-
const dir = event.key === "ArrowUp" ? "up" : "down";
|
|
550
|
-
const next = nextIndex(navCursor, history.length, dir);
|
|
551
|
-
if (next === null) {
|
|
552
|
-
const saved = savedDraft;
|
|
553
|
-
navCursor = null;
|
|
554
|
-
if (saved !== null) {
|
|
555
|
-
setNativeTextareaValue(textarea, saved);
|
|
556
|
-
savedDraft = null;
|
|
557
|
-
}
|
|
558
|
-
event.preventDefault();
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
if (navCursor === null && savedDraft === null) savedDraft = textarea.value;
|
|
562
|
-
const entry = entryAt(history, next);
|
|
563
|
-
if (entry === null) return;
|
|
564
|
-
navCursor = next;
|
|
565
|
-
setNativeTextareaValue(textarea, entry);
|
|
566
|
-
event.preventDefault();
|
|
567
|
-
};
|
|
568
|
-
document.addEventListener("keydown", handler, false);
|
|
569
|
-
return () => {
|
|
570
|
-
document.removeEventListener("keydown", handler, false);
|
|
571
|
-
};
|
|
572
|
-
}, "dsh-plugin-input-history: keydown listener");
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Set the textarea value via the native prototype setter and dispatch an
|
|
576
|
-
* `input` event so React's controlled-component onChange fires.
|
|
577
|
-
*
|
|
578
|
-
* React 18 tracks the textarea's value internally; directly assigning
|
|
579
|
-
* `textarea.value = x` does NOT trigger React's onChange because React's
|
|
580
|
-
* value tracker compares against its last-seen value. Using the native
|
|
581
|
-
* prototype setter bypasses React's tracker, and the dispatched `input`
|
|
582
|
-
* event makes React detect the change and run InputBar's `onChange` →
|
|
583
|
-
* `keyboard.setDraft(next)`. This is the same technique used by
|
|
584
|
-
* browser automation libraries (Playwright, Testing Library) to simulate
|
|
585
|
-
* user typing in React controlled inputs.
|
|
586
|
-
*
|
|
587
|
-
* @param textarea - the target textarea element.
|
|
588
|
-
* @param value - the new value to set.
|
|
589
|
-
*/
|
|
590
|
-
function setNativeTextareaValue(textarea, value) {
|
|
591
|
-
const proto = window.HTMLTextAreaElement.prototype;
|
|
592
|
-
const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
|
|
593
|
-
if (descriptor === void 0 || descriptor.set === void 0) {
|
|
594
|
-
textarea.value = value;
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
descriptor.set.call(textarea, value);
|
|
598
|
-
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
599
636
|
}
|
|
600
637
|
|
|
601
638
|
//#endregion
|