@huanlin/dsh-plugin-input-history 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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ dsh-plugin-input-history
2
+ Copyright (C) 2026 Huanlin
3
+
4
+ GNU AFFERO GENERAL PUBLIC LICENSE
5
+ Version 3, 19 November 2007
6
+
7
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
8
+ Everyone is permitted to copy and distribute verbatim copies
9
+ of this license document, but changing it is not allowed.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ <p align="center">
2
+ <a href="https://dshfind.com/zh/plugins/huanlinoto/dsh-plugin-input-history"><img src="https://dshfind.com/api/card/huanlinoto/dsh-plugin-input-history?lang=zh" alt="dsh-plugin-input-history card"></a>
3
+ </p>
4
+
5
+ # dsh-plugin-input-history
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@huanlin/dsh-plugin-input-history)](https://www.npmjs.com/package/@huanlin/dsh-plugin-input-history)
8
+
9
+ > 终端式 prompt 历史导航:在 DSH 输入框按 ↑/↓ 切换最近发送过的消息。
10
+
11
+ 在 DSH 的 prompt 输入框中按 **↑** 方向键,自动填入上一条已发送的 prompt;继续按 ↑ 向更旧的条目回溯,按 ↓ 向更新条目前进,按到最新条目之后再按 ↓ 恢复用户原本正在编辑的草稿。历史跨会话共享、持久化到 `localStorage`,刷新浏览器后仍然保留。
12
+
13
+ ## 功能
14
+
15
+ - **↑ / ↓ 切换历史**:在 DSH prompt 输入框(聊天页底部那个 textarea)按方向键,从最近一条 prompt 开始向旧回溯,或向新前进。
16
+ - **草稿保留**:切到历史预览后,按 ↓ 越过最新条目会自动恢复用户原本正在编辑的草稿——不会丢失在途文本。
17
+ - **跨会话全局持久化**:历史来自所有会话的 `user` + `steering` 消息,存到 `localStorage`(FIFO,500 条上限),刷新浏览器、新建会话、切换 workspace 都保留。
18
+ - **多行边界触发**:ArrowUp 仅在光标位于第一行任意位置时触发;ArrowDown 仅在最后一行任意位置时触发。多行编辑时方向键仍正常移动光标,不会被劫持。
19
+ - **IME 安全**:中文/日文输入法候选词状态按方向键不会被劫持(遵循 DSH core InputBar 的 IME 守卫约定,issue #535)。
20
+ - **斜杠菜单兼容**:斜杠命令菜单打开时,方向键归菜单高亮导航使用,插件不动。
21
+ - **零源码 patch**:纯插件,通过 `conversation.composer.dock` 隐藏条目挂载 `document` 级 `keydown` 监听器。不修改 DSH 源码任何文件。
22
+
23
+ ## 架构
24
+
25
+ 单 bundle 双入口(host `.` + 浏览器 `./client` + invariant `./invariant`),仿照 `dsh-spur` / `dsh-auto-blame`。
26
+
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` 确保健听器始终在线。
31
+ - **纯函数模块**:
32
+ - `src/client/history.ts`:`appendHistory` / `nextIndex` / `entryAt` / `HistoryStore`(localStorage 后端,quota 异常降级为内存)
33
+ - `src/client/dom.ts`:`cursorLineInfo`(多行边界判断)/ `findComposerTextarea`(DOM 查询)
34
+ - `src/client/ime.ts`:`isImeComposition`(IME 守卫)
35
+
36
+ ### 历史回填机制:native setter + dispatch input event
37
+
38
+ 监听器不通过 `inputActions.setDraft`(那是 per-session 的,通过 slot provide 注入,hero/blank 模式下 dock 不挂载时拿不到)。而是用 native prototype setter 改 textarea.value + dispatch `input` event,触发 InputBar 的 `onChange` → `keyboard.setDraft`——与用户手动输入走同一路径。这是浏览器自动化库(Playwright / Testing Library)模拟用户输入的标准手法。
39
+
40
+ ### Slot 选择
41
+
42
+ `conversation.composer.dock`(list,session 作用域)——编辑器卡片下方的条带,由 `ui-conversation` 拥有。dock 条目从框架接收 `InputZone`(owner:`session: ConversationSnapshot`)+ `SessionStandardProps`。dock 只负责历史收集;导航监听器在 `apply` 里,不依赖 dock 挂载。
43
+
44
+ ### 历史收集
45
+
46
+ 每次 render 读 `props.session.nodes`(point-in-time 快照),从尾向前找到第一个 `kind === 'user'` 或 `kind === 'steering'` 节点,提取其 `content` 中所有 `type === 'text'` 块的文本拼接。与上次看到的文本比较,不同则 `append` 到 `HistoryStore`。store 内部做去重(最新相等 no-op、旧出现移到末尾)和 FIFO 截断。
47
+
48
+ ### 键盘事件处理链
49
+
50
+ `document.addEventListener('keydown', handler, false)`(bubble 阶段,在 React 委托的 root handler 之后触发),监听器挂在 `apply` 里(非 dock 组件):
51
+
52
+ 1. **键过滤**:只处理 `ArrowUp` / `ArrowDown`。
53
+ 2. **IME 守卫**:`event.isComposing || event.keyCode === 229` → 放行。
54
+ 3. **斜杠菜单兼容**:`event.defaultPrevented` → 放行(InputBar 已消费,菜单打开中方向键移动高亮)。
55
+ 4. **textarea 定位**:从 `event.target` 向上找 `[data-composer-card]` 祖先,找其下的 `<textarea>`;找不到放行。
56
+ 5. **target 校验**:`event.target !== textarea` 放行(排除点击 composer 卡片 chrome 的情况)。
57
+ 6. **readOnly/disabled gate**:`textarea.readOnly || textarea.disabled` 放行(hero 模式 workspace picker trigger、submit 进行中)。
58
+ 7. **多行边界**:`cursorLineInfo(value, selStart, selEnd)` 计算;ArrowUp 仅 `atFirstLine` 触发,ArrowDown 仅 `atLastLine` 触发。
59
+ 8. **导航**:`nextIndex(cursor, total, dir)` 计算下一索引;`null` 表示越过最新端 → 恢复 `savedDraft`;否则 `setNativeTextareaValue(textarea, entry)` + `event.preventDefault()`。
60
+ 9. **草稿保存**:第一次从"未导航"切到"导航中"时,把当前 `textarea.value` 存到 `savedDraft`。
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。与用户手动输入走完全相同的路径。
65
+
66
+ ## 开发
67
+
68
+ ```sh
69
+ pnpm install # 安装开发依赖
70
+ pnpm run typecheck # tsc --noEmit(通过 ../dsh 解析 DSH 源码)
71
+ pnpm test # vitest run(纯函数单元测试)
72
+ pnpm run build # tsc + tsdown → lib/index.js, lib/invariant.js, lib/client.js
73
+ ```
74
+
75
+ ### 基于 DSH checkout 类型检查
76
+
77
+ `tsconfig.json` 继承 `../dsh/tsconfig.base.client.json`,继承其 `paths` 映射到同级 DSH checkout 的 `packages/*/*/src`。需在 `../dsh` 是 DSH checkout 根目录的同级布局下运行 typecheck。
78
+
79
+ ### 预构建 lib/
80
+
81
+ `lib/` 随仓库提交(与 `dsh-spur` / `dsh-auto-blame` 相同模式),git 安装时无需 `prepare` 脚本。开发时改动源码后跑 `pnpm run build`(或 `pnpm run bundle:client`)重建 `lib/`,再提交。
82
+
83
+ ## 安装
84
+
85
+ ```sh
86
+ # 从 npm 安装(推荐):
87
+ dsh plugin --profile web add @huanlin/dsh-plugin-input-history
88
+
89
+ # 从 GitHub 安装:
90
+ dsh plugin --profile web add github:huanlinoto/dsh-plugin-input-history
91
+
92
+ # 本地开发(link:):
93
+ dsh plugin --profile web add link:D:/Projects/deepseek-harness/dsh-plugin-input-history
94
+ ```
95
+
96
+ 安装后重启 `dsh web` 进程,浏览器硬刷新(`Ctrl+Shift+R`)。
97
+
98
+ ## 配置
99
+
100
+ 无配置。capacity 硬编码为 500 条(见已知限制)。
101
+
102
+ ## 已知限制
103
+
104
+ - **Capacity 硬编码。** 历史上限固定为 500 条(`DEFAULT_CAPACITY` in `src/client/history.ts`)。改为 `Config` 字段需要 host-client 间 RPC 通道(client bundle 与 host bundle 是独立的模块作用域,无法直接共享 Config)。如需调整,编辑源码后重建 `lib/`。
105
+ - **textarea DOM 句柄无私有 API。** 插件通过 `document.querySelector('[data-composer-card] textarea')` 定位 InputBar 的 textarea。`data-composer-card` 属性是 `ui-conversation` 包的内部实现(`InputBar.tsx:629`),目前稳定但无文档保证;上游若改名,定位器需要更新(单点:`findComposerTextarea`)。
106
+ - **斜杠菜单开/关状态无私有 API。** 通过 `event.defaultPrevented` 启发式判断:InputBar 的 onKeyDown 在菜单打开时方向键已 `preventDefault`(`InputBar.tsx:316`),bubble 阶段监听器据此识别"菜单已消费"。若上游改变该逻辑,启发式可能失效。
107
+ - **hero 模式(无 workspace 的 workspace picker)下不触发。** hero 模式的 textarea 是 readOnly 的 workspace picker trigger,`textarea.readOnly` 守卫会放行。但 blank session(有 workspace、textarea 可编辑)下正常工作——这是关键修复点,因为 DSH 把 blank session 当 hero 渲染导致 dock 不挂载,监听器放在 `apply` 里绕过了这个限制。
108
+ - **无跨 tab 同步。** 多窗口同时发消息时 localStorage 写竞争通过 try/catch 容错;最坏丢一条,下次 append 会纠正。
109
+ - **历史范围全局共享。** 不区分 workspace / session,所有 `user` + `steering` 消息进同一历史。如需按 workspace 隔离,需要扩展 `HistoryStore` 的 key 命名空间。
110
+
111
+ ## 设计参考
112
+
113
+ - 插件开发规范:`plugin-development-guide.md`
114
+ - DSH Native UI slot 速查:`DSH-Native-UI.md`
115
+ - 范本插件:`dsh-spur`(dock + inputActions 模式)、`dsh-auto-blame`(SuggestionBubbles)、`DSH-better-sidebar`(IME 守卫)
@@ -0,0 +1,14 @@
1
+ # Insert dsh-plugin-input-history: terminal-style prompt history navigation
2
+ # for the DSH composer. ArrowUp cycles to the previous sent prompt,
3
+ # ArrowDown to the next (or restores the in-progress draft when past the
4
+ # newest entry). History is collected from user + steering messages across
5
+ # all sessions and persisted in localStorage (FIFO, 500 entries).
6
+ #
7
+ # The plugin registers an invisible entry in `conversation.composer.dock`
8
+ # and attaches a bubble-phase `keydown` listener on `document`. The
9
+ # listener honours IME composition, the slash-menu arbitration contract
10
+ # (defers to `event.defaultPrevented`), and a multi-line cursor boundary
11
+ # check (ArrowUp triggers only on the first line, ArrowDown on the last).
12
+ - insert:
13
+ - id: dsh-plugin-input-history
14
+ name: '@huanlin/dsh-plugin-input-history'
package/lib/client.js ADDED
@@ -0,0 +1,467 @@
1
+ window.__ModuleLoader__.load({ id: "@huanlin/dsh-plugin-input-history", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ //#region rolldown:runtime
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
14
+ get: ((k) => from[k]).bind(null, key),
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
21
+ value: mod,
22
+ enumerable: true
23
+ }) : target, mod));
24
+
25
+ //#endregion
26
+ let react = require("react");
27
+ react = __toESM(react);
28
+ let react_jsx_runtime = require("react/jsx-runtime");
29
+ react_jsx_runtime = __toESM(react_jsx_runtime);
30
+
31
+ //#region src/client/history.ts
32
+ /**
33
+ * Prompt history store — pure functions over a string array.
34
+ *
35
+ * The store is a FIFO list of unique prompt strings, persisted to
36
+ * `localStorage`. Newest entries are at the end of the array. The
37
+ * navigation cursor walks backwards from the end (ArrowUp = older,
38
+ * ArrowDown = newer).
39
+ *
40
+ * The functions in this module are pure (no `localStorage` access) so
41
+ * they can be unit-tested without jsdom. The `HistoryStore` class below
42
+ * wires them to `localStorage` with try/catch containment — a quota
43
+ * exception or a disabled storage (private mode) degrades gracefully to
44
+ * an in-memory list that lives for the page lifetime.
45
+ *
46
+ * @module @huanlin/dsh-plugin-input-history/client/history
47
+ */
48
+ /** localStorage key (versioned; bump on schema changes to start fresh). */
49
+ const STORAGE_KEY = "dsh-plugin-input-history:v1";
50
+ /** Default capacity when none is configured. */
51
+ const DEFAULT_CAPACITY = 500;
52
+ /**
53
+ * Append a prompt to the history.
54
+ *
55
+ * Rules:
56
+ * - Empty / whitespace-only strings are ignored (the InputBar already
57
+ * rejects them at submit, but defensive).
58
+ * - When the new entry equals the most recent one, it is a no-op
59
+ * (avoids stacking duplicates from rapid resends).
60
+ * - When the new entry already exists earlier in the history, that
61
+ * earlier occurrence is removed (recency wins; the prompt moves to
62
+ * the end). This mirrors terminal shell behaviour.
63
+ * - When the array would exceed `capacity`, the oldest entries are
64
+ * dropped from the front (FIFO).
65
+ *
66
+ * @param history - the current history array (newest at end).
67
+ * @param prompt - the prompt to append.
68
+ * @param capacity - the maximum number of entries to retain.
69
+ * @returns the new history array (may be the same reference if no-op).
70
+ */
71
+ function appendHistory(history, prompt, capacity = DEFAULT_CAPACITY) {
72
+ const trimmed = prompt.trim();
73
+ if (trimmed === "") return history;
74
+ const lastIndex = history.lastIndexOf(trimmed);
75
+ if (lastIndex !== -1 && lastIndex === history.length - 1 && history.indexOf(trimmed) === lastIndex) return history;
76
+ const filtered = history.filter((item) => item !== trimmed);
77
+ filtered.push(trimmed);
78
+ const cap = Math.max(1, capacity);
79
+ if (filtered.length > cap) return filtered.slice(filtered.length - cap);
80
+ return filtered;
81
+ }
82
+ /**
83
+ * Navigation cursor for walking the history.
84
+ *
85
+ * The cursor is `null` when the user is not navigating (i.e. they are
86
+ * typing a fresh draft). ArrowUp sets it to the last index, then
87
+ * decrements; ArrowDown increments; when it would exceed `history.length
88
+ * - 1`, it returns to `null` (meaning "restore the in-progress draft").
89
+ *
90
+ * @param current - the current cursor (null = not navigating).
91
+ * @param total - the total number of history entries.
92
+ * @param dir - `'up'` (older) or `'down'` (newer).
93
+ * @returns the next cursor, or `null` when navigation falls off the
94
+ * newest end (caller should restore the saved draft).
95
+ */
96
+ function nextIndex(current, total, dir) {
97
+ if (total === 0) return null;
98
+ if (dir === "up") {
99
+ if (current === null) return total - 1;
100
+ if (current <= 0) return 0;
101
+ return current - 1;
102
+ }
103
+ if (current === null) return null;
104
+ if (current >= total - 1) return null;
105
+ return current + 1;
106
+ }
107
+ /**
108
+ * Read the history entry at a cursor, or `null` when the cursor is null.
109
+ *
110
+ * @param history - the history array.
111
+ * @param cursor - the navigation cursor.
112
+ * @returns the prompt at the cursor, or `null`.
113
+ */
114
+ function entryAt(history, cursor) {
115
+ if (cursor === null) return null;
116
+ if (cursor < 0 || cursor >= history.length) return null;
117
+ return history[cursor] ?? null;
118
+ }
119
+ /**
120
+ * History store bound to `localStorage`.
121
+ *
122
+ * The store reads once on construction (or on `reload()`) and keeps an
123
+ * in-memory copy. Writes go to both memory and `localStorage` inside a
124
+ * try/catch — a quota exception leaves the in-memory copy authoritative
125
+ * for the rest of the page lifetime. This trades cross-tab consistency
126
+ * for resilience: the store never throws on a write, and the worst case
127
+ * is that a tab keeps its own view until refresh.
128
+ *
129
+ * Cross-tab sync is intentionally NOT implemented: prompt history is
130
+ * append-mostly and a stale read across tabs is harmless (the next
131
+ * append corrects it). Listening to the `storage` event would add
132
+ * reactivity that the navigation UI does not need.
133
+ */
134
+ var HistoryStore = class {
135
+ items;
136
+ storage;
137
+ key;
138
+ /**
139
+ * @param capacity - maximum entries to retain (FIFO).
140
+ * @param storage - the storage backend (defaults to `localStorage` when available).
141
+ * @param key - the storage key (defaults to {@link STORAGE_KEY}).
142
+ */
143
+ constructor(capacity = DEFAULT_CAPACITY, storage, key = STORAGE_KEY) {
144
+ this.capacity = capacity;
145
+ this.storage = storage ?? safeLocalStorage();
146
+ this.key = key;
147
+ this.items = this.readFromStorage();
148
+ }
149
+ /** Current history snapshot (newest at end). */
150
+ get list() {
151
+ return this.items;
152
+ }
153
+ /** Number of entries currently stored. */
154
+ get length() {
155
+ return this.items.length;
156
+ }
157
+ /** Reload from storage (e.g. after a suspected external edit). Truncates to the current capacity. */
158
+ reload() {
159
+ const loaded = this.readFromStorage();
160
+ const cap = Math.max(1, this.capacity);
161
+ this.items = loaded.length > cap ? loaded.slice(loaded.length - cap) : loaded;
162
+ }
163
+ /**
164
+ * Append a prompt and persist. See {@link appendHistory} for rules.
165
+ * @returns the new history snapshot.
166
+ */
167
+ append(prompt) {
168
+ this.items = appendHistory(this.items, prompt, this.capacity);
169
+ this.writeToStorage();
170
+ return this.items;
171
+ }
172
+ /** Clear all history (used by tests and a future "clear" UI). */
173
+ clear() {
174
+ this.items = [];
175
+ this.writeToStorage();
176
+ }
177
+ readFromStorage() {
178
+ if (this.storage === null) return [];
179
+ try {
180
+ const raw = this.storage.getItem(this.key);
181
+ if (raw === null) return [];
182
+ const parsed = JSON.parse(raw);
183
+ if (!Array.isArray(parsed)) return [];
184
+ return parsed.filter((item) => typeof item === "string");
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+ writeToStorage() {
190
+ if (this.storage === null) return;
191
+ try {
192
+ this.storage.setItem(this.key, JSON.stringify(this.items));
193
+ } catch {}
194
+ }
195
+ };
196
+ /** Safe accessor for `localStorage` that returns null on any failure. */
197
+ function safeLocalStorage() {
198
+ try {
199
+ if (typeof localStorage === "undefined") return null;
200
+ return localStorage;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ //#endregion
207
+ //#region src/client/HistoryDock.tsx
208
+ /**
209
+ * Module-scope history store, initialized once on first dock mount.
210
+ * Shared with the keydown listener in `apply` via `getHistoryStore()`.
211
+ */
212
+ let historyStore = null;
213
+ /** Get the shared history store (initializes lazily on first call). */
214
+ function getHistoryStore() {
215
+ if (historyStore === null) historyStore = new HistoryStore(DEFAULT_CAPACITY);
216
+ return historyStore;
217
+ }
218
+ /**
219
+ * Render the invisible history-collection dock entry.
220
+ *
221
+ * @param props - dock runtime share (InputZone owner + session kit) + locale seat.
222
+ * @returns an `aria-hidden` anchor with zero layout footprint.
223
+ */
224
+ function HistoryDock({ session }) {
225
+ const store = getHistoryStore();
226
+ const lastSeenTextRef = (0, react.useRef)(null);
227
+ const lastText = latestUserOrSteeringText(session.nodes);
228
+ if (lastText !== null && lastText !== lastSeenTextRef.current) {
229
+ lastSeenTextRef.current = lastText;
230
+ store.append(lastText);
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
+ });
237
+ }
238
+ /**
239
+ * Extract the text of the latest `user` or `steering` node from a
240
+ * conversation snapshot's nodes list.
241
+ *
242
+ * Returns the concatenated text of all `type: 'text'` content blocks.
243
+ * Returns `null` when no user/steering node is present (e.g. a fresh
244
+ * session with only a system/context message).
245
+ *
246
+ * @param nodes - the conversation snapshot's `nodes` array.
247
+ */
248
+ function latestUserOrSteeringText(nodes) {
249
+ for (let i = nodes.length - 1; i >= 0; i--) {
250
+ const node = nodes[i];
251
+ if (node.kind !== "user" && node.kind !== "steering") continue;
252
+ const content = node.content;
253
+ if (content === void 0) continue;
254
+ let text = "";
255
+ for (const block of content) if (block.type === "text" && typeof block.text === "string") text += block.text;
256
+ return text;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ //#endregion
262
+ //#region src/client/ime.ts
263
+ /**
264
+ * IME-composition key guard.
265
+ *
266
+ * While a Chinese/Japanese/Korean input method is composing (the user is
267
+ * picking a candidate from the IME window), every pressed key BELONGS to
268
+ * the input method: arrows move the candidate highlight, Enter/Space
269
+ * confirm the composition, Escape cancels it. Page code must not process
270
+ * those keys — a history-navigation handler that calls `preventDefault()`
271
+ * on ArrowUp/ArrowDown during composition would silently break the IME:
272
+ * candidates stop responding, the composition gets torn apart, and only
273
+ * bare letters come out.
274
+ *
275
+ * The composition signal follows the DSH core convention (InputBar's IME
276
+ * guard, issue #535): `isComposing` for modern engines, keyCode 229 as
277
+ * the legacy signal engines emit without isComposing.
278
+ *
279
+ * @module @huanlin/dsh-plugin-input-history/client/ime
280
+ */
281
+ /** The pure decision: is this keyboard event part of an IME composition? */
282
+ function isImeComposition(event) {
283
+ return event.isComposing || event.keyCode === 229;
284
+ }
285
+
286
+ //#endregion
287
+ //#region src/client/dom.ts
288
+ /**
289
+ * Compute the caret's line position in a textarea value.
290
+ *
291
+ * Lines are split on `\n` (the textarea's own line break character). The
292
+ * caret must be collapsed (`selectionStart === selectionEnd`) for the
293
+ * `atFirstLine` / `atLastLine` flags to be true — a non-collapsed
294
+ * selection spanning multiple lines should not trigger history navigation.
295
+ *
296
+ * @param value - the textarea's current value.
297
+ * @param selectionStart - the textarea's `selectionStart`.
298
+ * @param selectionEnd - the textarea's `selectionEnd` (defaults to `selectionStart`).
299
+ * @returns the caret's line information.
300
+ */
301
+ function cursorLineInfo(value, selectionStart, selectionEnd = selectionStart) {
302
+ const rawStart = Math.min(selectionStart, selectionEnd);
303
+ const rawEnd = Math.max(selectionStart, selectionEnd);
304
+ const clampedStart = Math.max(0, Math.min(rawStart, value.length));
305
+ const collapsed = clampedStart === Math.max(clampedStart, Math.min(rawEnd, value.length));
306
+ const lines = value.split("\n");
307
+ const totalLines = lines.length;
308
+ let currentLine = 0;
309
+ let runningLength = 0;
310
+ for (let i = 0; i < totalLines; i++) {
311
+ const line = lines[i];
312
+ const lineEnd = runningLength + line.length;
313
+ const upperBound = i === totalLines - 1 ? lineEnd + 1 : lineEnd + 1;
314
+ if (clampedStart >= runningLength && clampedStart < upperBound) {
315
+ currentLine = i;
316
+ break;
317
+ }
318
+ runningLength = lineEnd + 1;
319
+ }
320
+ return {
321
+ currentLine,
322
+ totalLines,
323
+ atFirstLine: collapsed && currentLine === 0,
324
+ atLastLine: collapsed && currentLine === totalLines - 1
325
+ };
326
+ }
327
+ /**
328
+ * Locate the DSH composer textarea in the current document.
329
+ *
330
+ * Walks from the event target up to find the closest `[data-composer-card]`
331
+ * ancestor, then queries the descendant `<textarea>` inside it. Returns
332
+ * `null` when the target is not inside the composer card (e.g. the user
333
+ * is typing in another input or the textarea is momentarily absent).
334
+ *
335
+ * When called without an event target, falls back to a document-wide
336
+ * query — used in tests and ad-hoc probing.
337
+ *
338
+ * @param from - the event target (or any node inside the composer card).
339
+ * @returns the textarea element, or `null` when not found.
340
+ */
341
+ function findComposerTextarea(from) {
342
+ if (typeof document === "undefined") return null;
343
+ if (from === void 0) return document.querySelector("[data-composer-card] textarea");
344
+ if (from === null) return null;
345
+ const card = from instanceof Element ? from.closest("[data-composer-card]") : null;
346
+ if (card !== null) {
347
+ const ta = card.querySelector("textarea");
348
+ if (ta !== null) return ta;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ //#endregion
354
+ //#region src/client/locales.ts
355
+ /** Locale namespace id (matches the cordis.patch.yml plugin id). */
356
+ const NS = "dsh-plugin-input-history";
357
+ /** English dictionary. */
358
+ const en = {
359
+ ariaLabel: "Prompt history navigation (ArrowUp/ArrowDown)",
360
+ restoredDraft: "Restored in-progress draft",
361
+ noHistory: "No prompt history yet"
362
+ };
363
+ /** Chinese dictionary. */
364
+ const zh = {
365
+ ariaLabel: "提示词历史导航(上/下方向键)",
366
+ restoredDraft: "已恢复正在编辑的草稿",
367
+ noHistory: "暂无提示词历史"
368
+ };
369
+
370
+ //#endregion
371
+ //#region src/client/index.ts
372
+ /** Required services: slots + locale. */
373
+ const inject = ["slots", "locale"];
374
+ /**
375
+ * Navigation cursor + saved draft for the keydown listener. Module-scoped
376
+ * because the listener is attached once in `apply` and must persist across
377
+ * dock mount/unmount cycles.
378
+ */
379
+ let navCursor = null;
380
+ let savedDraft = null;
381
+ /**
382
+ * Client plugin body: register the dock + attach the keydown listener.
383
+ *
384
+ * @param ctx - client root context.
385
+ */
386
+ function apply(ctx) {
387
+ ctx.effect(() => ctx.locale.register(NS, {
388
+ zh,
389
+ en
390
+ }), "dsh-plugin-input-history: dictionaries");
391
+ ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
392
+ name: "conversation.composer.dock",
393
+ id: "dsh-plugin-input-history",
394
+ order: 100,
395
+ locale: NS
396
+ }, HistoryDock));
397
+ ctx.effect(() => {
398
+ if (typeof document === "undefined") return () => {};
399
+ const handler = (event) => {
400
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
401
+ if (isImeComposition(event)) return;
402
+ if (event.defaultPrevented) return;
403
+ const textarea = findComposerTextarea(event.target);
404
+ if (textarea === null) return;
405
+ if (event.target !== textarea) return;
406
+ if (textarea.readOnly || textarea.disabled) return;
407
+ const history = getHistoryStore().list;
408
+ const info = cursorLineInfo(textarea.value, textarea.selectionStart, textarea.selectionEnd);
409
+ if (event.key === "ArrowUp" && !info.atFirstLine) return;
410
+ if (event.key === "ArrowDown" && !info.atLastLine) return;
411
+ const dir = event.key === "ArrowUp" ? "up" : "down";
412
+ const next = nextIndex(navCursor, history.length, dir);
413
+ if (next === null) {
414
+ const saved = savedDraft;
415
+ navCursor = null;
416
+ if (saved !== null) {
417
+ setNativeTextareaValue(textarea, saved);
418
+ savedDraft = null;
419
+ }
420
+ event.preventDefault();
421
+ return;
422
+ }
423
+ if (navCursor === null && savedDraft === null) savedDraft = textarea.value;
424
+ const entry = entryAt(history, next);
425
+ if (entry === null) return;
426
+ navCursor = next;
427
+ setNativeTextareaValue(textarea, entry);
428
+ event.preventDefault();
429
+ };
430
+ document.addEventListener("keydown", handler, false);
431
+ return () => {
432
+ document.removeEventListener("keydown", handler, false);
433
+ };
434
+ }, "dsh-plugin-input-history: keydown listener");
435
+ }
436
+ /**
437
+ * Set the textarea value via the native prototype setter and dispatch an
438
+ * `input` event so React's controlled-component onChange fires.
439
+ *
440
+ * React 18 tracks the textarea's value internally; directly assigning
441
+ * `textarea.value = x` does NOT trigger React's onChange because React's
442
+ * value tracker compares against its last-seen value. Using the native
443
+ * prototype setter bypasses React's tracker, and the dispatched `input`
444
+ * event makes React detect the change and run InputBar's `onChange` →
445
+ * `keyboard.setDraft(next)`. This is the same technique used by
446
+ * browser automation libraries (Playwright, Testing Library) to simulate
447
+ * user typing in React controlled inputs.
448
+ *
449
+ * @param textarea - the target textarea element.
450
+ * @param value - the new value to set.
451
+ */
452
+ function setNativeTextareaValue(textarea, value) {
453
+ const proto = window.HTMLTextAreaElement.prototype;
454
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
455
+ if (descriptor === void 0 || descriptor.set === void 0) {
456
+ textarea.value = value;
457
+ return;
458
+ }
459
+ descriptor.set.call(textarea, value);
460
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
461
+ }
462
+
463
+ //#endregion
464
+ exports.apply = apply;
465
+ exports.inject = inject;
466
+ return module.exports; } });
467
+ //# sourceMappingURL=client.js.map