dsh-prompt-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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-prompt-history contributors
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 ADDED
@@ -0,0 +1,47 @@
1
+ # dsh-prompt-history
2
+
3
+ DSH Web 输入框的「类 Linux shell」提示词历史插件:在输入框里按 **↑ / ↓** 上下翻看之前发送过的消息。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ dsh plugin --profile web add dsh-prompt-history
9
+ ```
10
+
11
+ 然后刷新页面即可使用。
12
+
13
+ ## 用法
14
+
15
+ - **↑**:召回上一条发送过的消息(最新在前),替换当前输入;继续按 ↑ 往前翻
16
+ - **↓**:往后翻;翻到底时恢复你翻历史之前正在输入的那一行(readline 的 pending-line 行为)
17
+ - **编辑即退出**:浏览历史时一旦动手编辑,自动回到当前行,不再继续翻
18
+ - **不干扰其它操作**:`/` 与 `@` 建议菜单打开时,方向键仍归菜单导航;中文输入法(IME)组合、`Shift+↑` 选区、`Ctrl+↑` 跳词等修饰键组合均不拦截;发送中/已删除会话不响应
19
+
20
+ ## 特性
21
+
22
+ - **历史来自会话自身的消息记录**:直接读取会话快照中的用户消息节点(`user` / `steering`),随消息落地实时追加——与聊天记录严格一致,随会话持久化,刷新页面后依然可用,不需要任何配置或额外存储
23
+ - **连续重复自动合并**,浏览状态跟随会话切换自动重置
24
+ - 无 UI、无设置项,bundle 仅约 7 KB;依赖全部走官方 `@deepseek-ai/*` peer 依赖
25
+
26
+ ## 已知限制
27
+
28
+ - 切换会话时,只能召回当前已加载事件窗口内的旧消息(页面上新发送的都在);不做宿主侧历史拉取
29
+ - 纯文本召回:带图片/命令 chip 的消息不参与;召回内容为纯文本
30
+
31
+ ## 开发
32
+
33
+ ```sh
34
+ pnpm install
35
+ pnpm run typecheck # tsc --noEmit
36
+ pnpm run build # tsc(lib/types)+ tsdown(lib/index.js / lib/invariant.js / lib/client.js)
37
+ ```
38
+
39
+ 浏览器半区(`src/client/`)注册在 `conversation.input.right` 槽位,构建产物为 DSH `__ModuleLoader__` 闭包格式,外部依赖仅 `react`(其余由浏览器模块表提供)。
40
+
41
+ ## 原理
42
+
43
+ 插件是一个不可见的 composer 槽位条目:挂一个 document 捕获期 keydown 监听,仅在目标为 composer 输入框、无修饰键、非输入法组合、菜单未打开、会话非忙碌时接管 ↑/↓,通过 `inputActions.setDraft` 写入历史文本。历史列表由会话快照的 `user`/`steering` 节点按 seq 去重追加,浏览位置(index + 待恢复的当前行)保存在组件 ref 中。
44
+
45
+ ## License
46
+
47
+ [MIT](LICENSE)
package/README_EN.md ADDED
@@ -0,0 +1,47 @@
1
+ # dsh-prompt-history
2
+
3
+ Bash-like prompt history for the DSH web composer: **Up / Down** arrows recall previously submitted messages in the input box.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ dsh plugin --profile web add dsh-prompt-history
9
+ ```
10
+
11
+ Then refresh the page.
12
+
13
+ ## Usage
14
+
15
+ - **Up**: recall the previous submitted prompt (newest first), replacing the current draft; keep pressing Up to walk further back.
16
+ - **Down**: walk forward; at the bottom edge, restore the line you were typing before browsing began (readline pending-line behavior).
17
+ - **Edit exits browsing**: editing the draft while browsing drops back to the live line.
18
+ - **Does not interfere**: while the `/` or `@` suggestion menu is open, arrow keys stay with menu navigation; IME composition, `Shift+Up` selection, `Ctrl+Up` word-jumps and other modifier chords are never intercepted; busy/removed sessions do not respond.
19
+
20
+ ## Features
21
+
22
+ - **History comes from the session's own message log**: reads the conversation snapshot's user nodes (`user` / `steering`) and appends as they land — strictly consistent with the transcript, persisted with the session, survives page reloads, and needs no configuration or extra storage.
23
+ - **Consecutive duplicates collapse**; browse state resets on session switch.
24
+ - No UI, no settings; the client bundle is ~7 KB and depends only on the official `@deepseek-ai/*` peer packages.
25
+
26
+ ## Known limitations
27
+
28
+ - On a session switch, only the currently loaded event window's messages are recallable (everything submitted while the page is open is included); there is no host-side history fetch.
29
+ - Plain text only: image-only or chip-bearing messages are not recalled; recalled drafts are plain text.
30
+
31
+ ## Development
32
+
33
+ ```sh
34
+ pnpm install
35
+ pnpm run typecheck # tsc --noEmit
36
+ pnpm run build # tsc (lib/types) + tsdown (lib/index.js / lib/invariant.js / lib/client.js)
37
+ ```
38
+
39
+ The browser half (`src/client/`) registers into the `conversation.input.right` slot; the build emits the DSH `__ModuleLoader__` closure format with `react` as the only external (everything else comes from the browser module table).
40
+
41
+ ## How it works
42
+
43
+ The plugin is an invisible composer slot entry that mounts a capture-phase `keydown` listener on the document. It takes over Up/Down only when the target is the composer textarea, no modifiers are held, no IME composition is active, no suggestion menu is open, and the session is not busy — then writes the recalled text through `inputActions.setDraft`. The history list is fed from the snapshot's `user`/`steering` nodes (deduped by seq); the browse position (index plus the pending line to restore) lives in component refs.
44
+
45
+ ## License
46
+
47
+ [MIT](LICENSE)
@@ -0,0 +1,8 @@
1
+ # dsh-prompt-history bundle patch — bash-like prompt history for the composer.
2
+ #
3
+ # Mounts the browser half by inserting one row into the dsh.client roster; the
4
+ # loader imports the package root (empty node half) and the modules node half
5
+ # serves exports["./client"] to the browser.
6
+ - insert:
7
+ - id: dsh-prompt-history
8
+ name: dsh-prompt-history
package/lib/client.js ADDED
@@ -0,0 +1,180 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-prompt-history",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ //#region src/client/InputHistory.tsx
9
+ /**
10
+ * InputHistory: bash-like prompt history for the composer (dsh-prompt-history).
11
+ * Renders nothing — it mounts one capture-phase keydown listener on the
12
+ * document while the session's composer card is live. Up recalls previously
13
+ * submitted prompts (newest first), Down walks forward and restores the line
14
+ * that was being typed before browsing began; editing the draft while browsing
15
+ * drops back to the live line. The '/' and '@' suggestion menus keep their own
16
+ * arrow-key navigation: while the menu (role=listbox inside the composer card)
17
+ * is open, this listener declines and the input trigger pipeline owns the keys.
18
+ *
19
+ * History is fed from the conversation snapshot's user nodes (kind 'user' and
20
+ * 'steering'), appended as they land — so everything submitted while the page
21
+ * is open is recallable even after the session event window slides. Consecutive
22
+ * duplicates collapse; on a session switch the window's in-window user messages
23
+ * seed the list. Unlike a parallel storage ring, the history IS the session's
24
+ * own message log: it stays consistent with the transcript, survives reloads
25
+ * through the session itself, and needs no configuration or extra storage.
26
+ *
27
+ * Interception guards: composer textarea target only, no modifier keys
28
+ * (Shift+Up still extends selection), no IME composition, machine not
29
+ * adjudicating/submitting, session not removed, and a non-empty history.
30
+ */
31
+ /** Not-browsing state; also the reset target after edits and session switches. */
32
+ const RESET_BROWSE = {
33
+ index: -1,
34
+ saved: "",
35
+ lastSet: null
36
+ };
37
+ /** The composer card's own textarea is the only interception target. */
38
+ const COMPOSER_CARD = "[data-composer-card]";
39
+ /** The suggestion menu (slash/at) renders a listbox inside the card while open. */
40
+ const OPEN_MENU = "[role=\"listbox\"]";
41
+ /** Extract the trimmed plain text of one user-submitted message node; null when empty. */
42
+ function promptText(node) {
43
+ if (node.kind !== "user" && node.kind !== "steering") return null;
44
+ let text = "";
45
+ for (const block of node.content) if (block.type === "text") text += block.text;
46
+ const trimmed = text.trim();
47
+ return trimmed === "" ? null : trimmed;
48
+ }
49
+ /**
50
+ * The composer history entry: no visible UI, one keydown listener.
51
+ * @param props - framework standard kit (useInput/useSession/inputActions/sessionId).
52
+ * @returns null (the entry is invisible chrome).
53
+ */
54
+ function InputHistory({ useInput, useSession, inputActions, sessionId }) {
55
+ const draft = useInput((s) => s.draft);
56
+ const phase = useInput((s) => s.phase);
57
+ const nodes = useSession((s) => s.nodes);
58
+ const removed = useSession((s) => s.removed) ?? false;
59
+ /** Submitted prompt texts, oldest → newest (per session; append-only). */
60
+ const historyRef = (0, react.useRef)([]);
61
+ /** User-node seqs already folded into historyRef (append-once dedup). */
62
+ const seenRef = (0, react.useRef)(/* @__PURE__ */ new Set());
63
+ const browseRef = (0, react.useRef)(RESET_BROWSE);
64
+ const liveRef = (0, react.useRef)({
65
+ draft,
66
+ phase,
67
+ removed,
68
+ inputActions
69
+ });
70
+ liveRef.current = {
71
+ draft,
72
+ phase,
73
+ removed,
74
+ inputActions
75
+ };
76
+ (0, react.useEffect)(() => {
77
+ historyRef.current = [];
78
+ seenRef.current = /* @__PURE__ */ new Set();
79
+ browseRef.current = RESET_BROWSE;
80
+ }, [sessionId]);
81
+ (0, react.useEffect)(() => {
82
+ const seen = seenRef.current;
83
+ const history = historyRef.current;
84
+ for (const node of nodes) {
85
+ if (node.kind !== "user" && node.kind !== "steering") continue;
86
+ if (seen.has(node.seq)) continue;
87
+ seen.add(node.seq);
88
+ const text = promptText(node);
89
+ if (text === null) continue;
90
+ if (history[history.length - 1] !== text) history.push(text);
91
+ }
92
+ }, [nodes]);
93
+ (0, react.useEffect)(() => {
94
+ const browse = browseRef.current;
95
+ if (browse.index !== -1 && draft !== browse.lastSet) browseRef.current = RESET_BROWSE;
96
+ }, [draft]);
97
+ (0, react.useEffect)(() => {
98
+ const onKeyDown = (e) => {
99
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
100
+ const target = e.target;
101
+ if (!(target instanceof HTMLTextAreaElement)) return;
102
+ const card = target.closest(COMPOSER_CARD);
103
+ if (card === null) return;
104
+ if (e.isComposing || e.keyCode === 229) return;
105
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
106
+ if (card.querySelector(OPEN_MENU) !== null) return;
107
+ const live = liveRef.current;
108
+ if (live.phase === "adjudicating" || live.phase === "submitting" || live.removed) return;
109
+ const history = historyRef.current;
110
+ if (history.length === 0) return;
111
+ const recall = (index) => history[index] ?? "";
112
+ const browse = browseRef.current;
113
+ if (e.key === "ArrowDown" && browse.index === -1) return;
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ if (e.key === "ArrowUp") {
117
+ if (browse.index === -1) {
118
+ const index = history.length - 1;
119
+ browseRef.current = {
120
+ index,
121
+ saved: live.draft,
122
+ lastSet: recall(index)
123
+ };
124
+ live.inputActions.setDraft(recall(index));
125
+ } else {
126
+ const index = Math.max(0, browse.index - 1);
127
+ browseRef.current = {
128
+ ...browse,
129
+ index,
130
+ lastSet: recall(index)
131
+ };
132
+ live.inputActions.setDraft(recall(index));
133
+ }
134
+ return;
135
+ }
136
+ if (browse.index + 1 >= history.length) {
137
+ browseRef.current = RESET_BROWSE;
138
+ live.inputActions.setDraft(browse.saved);
139
+ } else {
140
+ const index = browse.index + 1;
141
+ browseRef.current = {
142
+ ...browse,
143
+ index,
144
+ lastSet: recall(index)
145
+ };
146
+ live.inputActions.setDraft(recall(index));
147
+ }
148
+ };
149
+ document.addEventListener("keydown", onKeyDown, true);
150
+ return () => {
151
+ document.removeEventListener("keydown", onKeyDown, true);
152
+ };
153
+ }, []);
154
+ return null;
155
+ }
156
+ //#endregion
157
+ //#region src/client/index.ts
158
+ /** Required services: the slot registry the entry registers into. */
159
+ const inject = ["slots"];
160
+ /**
161
+ * Client plugin body: register the composer input-history entry. The
162
+ * declaration lives in ui-conversation, whose apply order is unconstrained —
163
+ * slots.inject waits on the actual declaration and retires the contribution
164
+ * with this plugin's fiber.
165
+ * @param ctx - client root context.
166
+ */
167
+ function apply(ctx) {
168
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
169
+ name: "conversation.input.right",
170
+ id: "dsh-prompt-history"
171
+ }, InputHistory));
172
+ }
173
+ //#endregion
174
+ exports.apply = apply;
175
+ exports.inject = inject;
176
+ return module.exports;
177
+ }
178
+ });
179
+
180
+ //# sourceMappingURL=client.js.map
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ //#region lib/types/index.js
2
+ /**
3
+ * dsh-prompt-history, node half. Pure UI plugin: the empty apply exists so
4
+ * the plugin appears in the host cordis.yml / Loader; the browser half ships
5
+ * via exports["./client"], discovered through the package.json dsh.client
6
+ * declaration.
7
+ */
8
+ /** Host plugin body — no host-side behavior for this UI-only plugin. */
9
+ function apply() {}
10
+ //#endregion
11
+ export { apply };
@@ -0,0 +1,25 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `dsh-prompt-history`.
4
+ * @module dsh-prompt-history/invariant
5
+ */
6
+ const PACKAGE_NAME = "dsh-prompt-history";
7
+ /** Cordis companion plugin name. */
8
+ const name = "dsh-prompt-history-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the composer slot registration is a registry-owned
13
+ * registration whose disposal is proven by the HMR-safety spec, and the
14
+ * per-session keydown listener is React-effect-owned (unmount tears it down).
15
+ * The plugin emits no cordis events and owns no cross-plugin mutable state.
16
+ */
17
+ const install = () => {};
18
+ /**
19
+ * Register this package's invariant companion.
20
+ * @param ctx - Cordis context carrying the invariant service.
21
+ * @returns the installed registration's disposer after setup succeeds.
22
+ */
23
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
24
+ //#endregion
25
+ export { apply, inject, name };
@@ -0,0 +1,10 @@
1
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
+ /** Full props of the input-history entry: framework standard kit + owner share. */
3
+ export type InputHistoryProps = PropsRuntime<'conversation.input.right'>;
4
+ /**
5
+ * The composer history entry: no visible UI, one keydown listener.
6
+ * @param props - framework standard kit (useInput/useSession/inputActions/sessionId).
7
+ * @returns null (the entry is invisible chrome).
8
+ */
9
+ export declare function InputHistory({ useInput, useSession, inputActions, sessionId }: InputHistoryProps): null;
10
+ //# sourceMappingURL=InputHistory.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Prompt-history plugin, browser half: one invisible entry in the composer's
3
+ * input.right tool row that mounts the capture-phase keyboard listener (see
4
+ * InputHistory.tsx for the full behavior contract).
5
+ */
6
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
7
+ /** Required services: the slot registry the entry registers into. */
8
+ export declare const inject: string[];
9
+ /**
10
+ * Client plugin body: register the composer input-history entry. The
11
+ * declaration lives in ui-conversation, whose apply order is unconstrained —
12
+ * slots.inject waits on the actual declaration and retires the contribution
13
+ * with this plugin's fiber.
14
+ * @param ctx - client root context.
15
+ */
16
+ export declare function apply(ctx: ClientContext): void;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,13 @@
1
+ import { InputHistory } from "./InputHistory.js";
2
+ /** Required services: the slot registry the entry registers into. */
3
+ export const inject = ['slots'];
4
+ /**
5
+ * Client plugin body: register the composer input-history entry. The
6
+ * declaration lives in ui-conversation, whose apply order is unconstrained —
7
+ * slots.inject waits on the actual declaration and retires the contribution
8
+ * with this plugin's fiber.
9
+ * @param ctx - client root context.
10
+ */
11
+ export function apply(ctx) {
12
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({ name: 'conversation.input.right', id: 'dsh-prompt-history' }, InputHistory));
13
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * dsh-prompt-history, node half. Pure UI plugin: the empty apply exists so
3
+ * the plugin appears in the host cordis.yml / Loader; the browser half ships
4
+ * via exports["./client"], discovered through the package.json dsh.client
5
+ * declaration.
6
+ */
7
+ /** Host plugin body — no host-side behavior for this UI-only plugin. */
8
+ export declare function apply(): void;
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * dsh-prompt-history, node half. Pure UI plugin: the empty apply exists so
3
+ * the plugin appears in the host cordis.yml / Loader; the browser half ships
4
+ * via exports["./client"], discovered through the package.json dsh.client
5
+ * declaration.
6
+ */
7
+ /** Host plugin body — no host-side behavior for this UI-only plugin. */
8
+ export function apply() { }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-prompt-history`.
3
+ * @module dsh-prompt-history/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "dsh-prompt-history-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-prompt-history`.
3
+ * @module dsh-prompt-history/invariant
4
+ */
5
+ const PACKAGE_NAME = 'dsh-prompt-history';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'dsh-prompt-history-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: the composer slot registration is a registry-owned
12
+ * registration whose disposal is proven by the HMR-safety spec, and the
13
+ * per-session keydown listener is React-effect-owned (unmount tears it down).
14
+ * The plugin emits no cordis events and owns no cross-plugin mutable state.
15
+ */
16
+ const install = () => { };
17
+ /**
18
+ * Register this package's invariant companion.
19
+ * @param ctx - Cordis context carrying the invariant service.
20
+ * @returns the installed registration's disposer after setup succeeds.
21
+ */
22
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
+ /* jscpd:ignore-end */
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "dsh-prompt-history",
3
+ "version": "0.1.0",
4
+ "description": "DSH web plugin: bash-like prompt history in the composer — Up/Down arrows recall previously submitted messages, sourced from the session's own message log (zero config, zero extra storage).",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "plugin",
10
+ "history",
11
+ "composer",
12
+ "input",
13
+ "prompt"
14
+ ],
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/Xiaofei-fei/dsh-prompt-history"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "main": "lib/index.js",
24
+ "types": "lib/types/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./lib/types/index.d.ts",
28
+ "default": "./lib/index.js"
29
+ },
30
+ "./invariant": {
31
+ "types": "./lib/types/invariant.d.ts",
32
+ "default": "./lib/invariant.js"
33
+ },
34
+ "./client": {
35
+ "types": "./lib/types/client/index.d.ts",
36
+ "default": "./lib/client.js"
37
+ },
38
+ "./src/*": "./src/*",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-conversation"
52
+ ],
53
+ "platform": "web"
54
+ }
55
+ },
56
+ "files": [
57
+ "lib/index.js",
58
+ "lib/invariant.js",
59
+ "lib/client.js",
60
+ "lib/types/**/*.d.ts",
61
+ "src",
62
+ "cordis.patch.yml",
63
+ "README.md",
64
+ "README_EN.md",
65
+ "LICENSE"
66
+ ],
67
+ "license": "MIT",
68
+ "peerDependencies": {
69
+ "@deepseek-ai/cordis": "^4.0.1",
70
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
71
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
74
+ "react": "^18.2.0"
75
+ },
76
+ "devDependencies": {
77
+ "@deepseek-ai/cordis": "^4.0.1",
78
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.7",
79
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.7",
80
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7",
81
+ "@deepseek-ai/dsh-invariants": "0.1.0-rc.7",
82
+ "@types/react": "~18.3.1",
83
+ "react": "^18.2.0",
84
+ "tsdown": "^0.22.2",
85
+ "typescript": "^5.6.0",
86
+ "unrun": "^0.3.1"
87
+ },
88
+ "scripts": {
89
+ "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
90
+ "prepare": "pnpm build",
91
+ "typecheck": "tsc --noEmit",
92
+ "bundle": "tsdown",
93
+ "watch": "tsdown --watch"
94
+ }
95
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * InputHistory: bash-like prompt history for the composer (dsh-prompt-history).
3
+ * Renders nothing — it mounts one capture-phase keydown listener on the
4
+ * document while the session's composer card is live. Up recalls previously
5
+ * submitted prompts (newest first), Down walks forward and restores the line
6
+ * that was being typed before browsing began; editing the draft while browsing
7
+ * drops back to the live line. The '/' and '@' suggestion menus keep their own
8
+ * arrow-key navigation: while the menu (role=listbox inside the composer card)
9
+ * is open, this listener declines and the input trigger pipeline owns the keys.
10
+ *
11
+ * History is fed from the conversation snapshot's user nodes (kind 'user' and
12
+ * 'steering'), appended as they land — so everything submitted while the page
13
+ * is open is recallable even after the session event window slides. Consecutive
14
+ * duplicates collapse; on a session switch the window's in-window user messages
15
+ * seed the list. Unlike a parallel storage ring, the history IS the session's
16
+ * own message log: it stays consistent with the transcript, survives reloads
17
+ * through the session itself, and needs no configuration or extra storage.
18
+ *
19
+ * Interception guards: composer textarea target only, no modifier keys
20
+ * (Shift+Up still extends selection), no IME composition, machine not
21
+ * adjudicating/submitting, session not removed, and a non-empty history.
22
+ */
23
+ import { useEffect, useRef } from 'react'
24
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
25
+ // Type-only: the ui-conversation SlotMap merge (the input.right entry) and the
26
+ // session standard kit members (useInput/inputActions).
27
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
28
+ // Type-only: the runtime SessionStandardProps merge (useSession/sessionId) and
29
+ // the conversation node union.
30
+ import type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
31
+
32
+ /** Full props of the input-history entry: framework standard kit + owner share. */
33
+ export type InputHistoryProps = PropsRuntime<'conversation.input.right'>
34
+
35
+ /** One browse-position snapshot; index -1 = showing the live draft (not browsing). */
36
+ interface BrowseState {
37
+ /** History index currently shown; -1 = the live line. */
38
+ readonly index: number
39
+ /** The draft saved when browsing started; restored at the bottom edge. */
40
+ readonly saved: string
41
+ /** The exact draft our own setDraft last wrote (user-edit detection). */
42
+ readonly lastSet: string | null
43
+ }
44
+
45
+ /** Not-browsing state; also the reset target after edits and session switches. */
46
+ const RESET_BROWSE: BrowseState = { index: -1, saved: '', lastSet: null }
47
+
48
+ /** The composer card's own textarea is the only interception target. */
49
+ const COMPOSER_CARD = '[data-composer-card]'
50
+ /** The suggestion menu (slash/at) renders a listbox inside the card while open. */
51
+ const OPEN_MENU = '[role="listbox"]'
52
+
53
+ /** Extract the trimmed plain text of one user-submitted message node; null when empty. */
54
+ function promptText(node: ConversationNode): string | null {
55
+ if (node.kind !== 'user' && node.kind !== 'steering') return null
56
+ let text = ''
57
+ for (const block of node.content) {
58
+ if (block.type === 'text') text += block.text
59
+ }
60
+ const trimmed = text.trim()
61
+ return trimmed === '' ? null : trimmed
62
+ }
63
+
64
+ /**
65
+ * The composer history entry: no visible UI, one keydown listener.
66
+ * @param props - framework standard kit (useInput/useSession/inputActions/sessionId).
67
+ * @returns null (the entry is invisible chrome).
68
+ */
69
+ export function InputHistory({ useInput, useSession, inputActions, sessionId }: InputHistoryProps) {
70
+ // Latest machine/session facts at event time (the listener mounts once).
71
+ const draft = useInput(s => s.draft)
72
+ const phase = useInput(s => s.phase)
73
+ const nodes = useSession(s => s.nodes)
74
+ const removed = useSession(s => s.removed) ?? false
75
+
76
+ /** Submitted prompt texts, oldest → newest (per session; append-only). */
77
+ const historyRef = useRef<string[]>([])
78
+ /** User-node seqs already folded into historyRef (append-once dedup). */
79
+ const seenRef = useRef<Set<number>>(new Set())
80
+ const browseRef = useRef<BrowseState>(RESET_BROWSE)
81
+ const liveRef = useRef({ draft, phase, removed, inputActions })
82
+ liveRef.current = { draft, phase, removed, inputActions }
83
+
84
+ // Session switch: start a fresh history, seen set, and browse position.
85
+ useEffect(() => {
86
+ historyRef.current = []
87
+ seenRef.current = new Set()
88
+ browseRef.current = RESET_BROWSE
89
+ }, [sessionId])
90
+
91
+ // Fold newly arrived user messages into the history (window slides; the
92
+ // append-only list survives it).
93
+ useEffect(() => {
94
+ const seen = seenRef.current
95
+ const history = historyRef.current
96
+ for (const node of nodes) {
97
+ if (node.kind !== 'user' && node.kind !== 'steering') continue
98
+ if (seen.has(node.seq)) continue
99
+ seen.add(node.seq)
100
+ const text = promptText(node)
101
+ if (text === null) continue
102
+ if (history[history.length - 1] !== text) history.push(text)
103
+ }
104
+ }, [nodes])
105
+
106
+ // Any draft change that is not our own history write ends the browse
107
+ // session (bash drops the recalled line when you edit it).
108
+ useEffect(() => {
109
+ const browse = browseRef.current
110
+ if (browse.index !== -1 && draft !== browse.lastSet) browseRef.current = RESET_BROWSE
111
+ }, [draft])
112
+
113
+ useEffect(() => {
114
+ const onKeyDown = (e: KeyboardEvent): void => {
115
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
116
+ const target = e.target
117
+ if (!(target instanceof HTMLTextAreaElement)) return
118
+ const card = target.closest(COMPOSER_CARD)
119
+ if (card === null) return
120
+ // IME composition and modifier chords stay native (Shift+Up selects text,
121
+ // Ctrl+Up moves by word).
122
+ if (e.isComposing || e.keyCode === 229) return
123
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return
124
+ // The suggestion menu owns the arrows while it is open.
125
+ if (card.querySelector(OPEN_MENU) !== null) return
126
+ const live = liveRef.current
127
+ if (live.phase === 'adjudicating' || live.phase === 'submitting' || live.removed) return
128
+ const history = historyRef.current
129
+ if (history.length === 0) return
130
+ // The browse indexes below are always in bounds (derived from the length
131
+ // or a clamped prior index), so the undefined arm of the indexed read is
132
+ // unreachable; the fallback keeps the write and the lastSet echo aligned.
133
+ const recall = (index: number): string => history[index] ?? ''
134
+ const browse = browseRef.current
135
+
136
+ if (e.key === 'ArrowDown' && browse.index === -1) return // native caret-down on the live line
137
+
138
+ e.preventDefault()
139
+ e.stopPropagation()
140
+
141
+ if (e.key === 'ArrowUp') {
142
+ if (browse.index === -1) {
143
+ // Enter browsing: save the live line, recall the newest prompt.
144
+ const index = history.length - 1
145
+ browseRef.current = { index, saved: live.draft, lastSet: recall(index) }
146
+ live.inputActions.setDraft(recall(index))
147
+ } else {
148
+ const index = Math.max(0, browse.index - 1)
149
+ browseRef.current = { ...browse, index, lastSet: recall(index) }
150
+ live.inputActions.setDraft(recall(index))
151
+ }
152
+ return
153
+ }
154
+
155
+ // ArrowDown while browsing.
156
+ if (browse.index + 1 >= history.length) {
157
+ // Bottom edge: restore the saved live line and stop browsing.
158
+ browseRef.current = RESET_BROWSE
159
+ live.inputActions.setDraft(browse.saved)
160
+ } else {
161
+ const index = browse.index + 1
162
+ browseRef.current = { ...browse, index, lastSet: recall(index) }
163
+ live.inputActions.setDraft(recall(index))
164
+ }
165
+ }
166
+ document.addEventListener('keydown', onKeyDown, true)
167
+ return () => { document.removeEventListener('keydown', onKeyDown, true) }
168
+ }, [])
169
+
170
+ return null
171
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Prompt-history plugin, browser half: one invisible entry in the composer's
3
+ * input.right tool row that mounts the capture-phase keyboard listener (see
4
+ * InputHistory.tsx for the full behavior contract).
5
+ */
6
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
7
+ // Type-only: pulls the ui-conversation SlotMap merge (the input.right entry)
8
+ // and the session standard kit members used by the component.
9
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
10
+ import { InputHistory } from './InputHistory.tsx'
11
+
12
+ /** Required services: the slot registry the entry registers into. */
13
+ export const inject = ['slots']
14
+
15
+ /**
16
+ * Client plugin body: register the composer input-history entry. The
17
+ * declaration lives in ui-conversation, whose apply order is unconstrained —
18
+ * slots.inject waits on the actual declaration and retires the contribution
19
+ * with this plugin's fiber.
20
+ * @param ctx - client root context.
21
+ */
22
+ export function apply(ctx: ClientContext): void {
23
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
24
+ { name: 'conversation.input.right', id: 'dsh-prompt-history' },
25
+ InputHistory,
26
+ ))
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * dsh-prompt-history, node half. Pure UI plugin: the empty apply exists so
3
+ * the plugin appears in the host cordis.yml / Loader; the browser half ships
4
+ * via exports["./client"], discovered through the package.json dsh.client
5
+ * declaration.
6
+ */
7
+
8
+ /** Host plugin body — no host-side behavior for this UI-only plugin. */
9
+ export function apply(): void {}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-prompt-history`.
3
+ * @module dsh-prompt-history/invariant
4
+ */
5
+
6
+ /* jscpd:ignore-start */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
+
10
+ const PACKAGE_NAME = 'dsh-prompt-history'
11
+
12
+ /** Cordis companion plugin name. */
13
+ export const name = 'dsh-prompt-history-invariant'
14
+ /** Service required before the companion can reserve package ownership. */
15
+ export const inject = ['invariants']
16
+
17
+ /**
18
+ * No runtime invariant: the composer slot registration is a registry-owned
19
+ * registration whose disposal is proven by the HMR-safety spec, and the
20
+ * per-session keydown listener is React-effect-owned (unmount tears it down).
21
+ * The plugin emits no cordis events and owns no cross-plugin mutable state.
22
+ */
23
+ const install: InvariantInstaller = () => {}
24
+
25
+ /**
26
+ * Register this package's invariant companion.
27
+ * @param ctx - Cordis context carrying the invariant service.
28
+ * @returns the installed registration's disposer after setup succeeds.
29
+ */
30
+ export const apply = (ctx: Context): Promise<() => void> =>
31
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
32
+ /* jscpd:ignore-end */