@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 +9 -0
- package/README.md +115 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +467 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +12 -0
- package/lib/invariant.js +24 -0
- package/lib/types/client/HistoryDock.d.ts +31 -0
- package/lib/types/client/HistoryDock.js +83 -0
- package/lib/types/client/dom.d.ts +57 -0
- package/lib/types/client/dom.js +99 -0
- package/lib/types/client/history.d.ts +105 -0
- package/lib/types/client/history.js +203 -0
- package/lib/types/client/ime.d.ts +23 -0
- package/lib/types/client/ime.js +22 -0
- package/lib/types/client/index.d.ts +42 -0
- package/lib/types/client/index.js +147 -0
- package/lib/types/client/locales.d.ts +17 -0
- package/lib/types/client/locales.js +23 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.js +26 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +26 -0
- package/package.json +105 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt history store — pure functions over a string array.
|
|
3
|
+
*
|
|
4
|
+
* The store is a FIFO list of unique prompt strings, persisted to
|
|
5
|
+
* `localStorage`. Newest entries are at the end of the array. The
|
|
6
|
+
* navigation cursor walks backwards from the end (ArrowUp = older,
|
|
7
|
+
* ArrowDown = newer).
|
|
8
|
+
*
|
|
9
|
+
* The functions in this module are pure (no `localStorage` access) so
|
|
10
|
+
* they can be unit-tested without jsdom. The `HistoryStore` class below
|
|
11
|
+
* wires them to `localStorage` with try/catch containment — a quota
|
|
12
|
+
* exception or a disabled storage (private mode) degrades gracefully to
|
|
13
|
+
* an in-memory list that lives for the page lifetime.
|
|
14
|
+
*
|
|
15
|
+
* @module @huanlin/dsh-plugin-input-history/client/history
|
|
16
|
+
*/
|
|
17
|
+
/** localStorage key (versioned; bump on schema changes to start fresh). */
|
|
18
|
+
export const STORAGE_KEY = 'dsh-plugin-input-history:v1';
|
|
19
|
+
/** Default capacity when none is configured. */
|
|
20
|
+
export const DEFAULT_CAPACITY = 500;
|
|
21
|
+
/**
|
|
22
|
+
* Append a prompt to the history.
|
|
23
|
+
*
|
|
24
|
+
* Rules:
|
|
25
|
+
* - Empty / whitespace-only strings are ignored (the InputBar already
|
|
26
|
+
* rejects them at submit, but defensive).
|
|
27
|
+
* - When the new entry equals the most recent one, it is a no-op
|
|
28
|
+
* (avoids stacking duplicates from rapid resends).
|
|
29
|
+
* - When the new entry already exists earlier in the history, that
|
|
30
|
+
* earlier occurrence is removed (recency wins; the prompt moves to
|
|
31
|
+
* the end). This mirrors terminal shell behaviour.
|
|
32
|
+
* - When the array would exceed `capacity`, the oldest entries are
|
|
33
|
+
* dropped from the front (FIFO).
|
|
34
|
+
*
|
|
35
|
+
* @param history - the current history array (newest at end).
|
|
36
|
+
* @param prompt - the prompt to append.
|
|
37
|
+
* @param capacity - the maximum number of entries to retain.
|
|
38
|
+
* @returns the new history array (may be the same reference if no-op).
|
|
39
|
+
*/
|
|
40
|
+
export function appendHistory(history, prompt, capacity = DEFAULT_CAPACITY) {
|
|
41
|
+
const trimmed = prompt.trim();
|
|
42
|
+
if (trimmed === '')
|
|
43
|
+
return history;
|
|
44
|
+
// Latest-equal with no earlier duplicate: true no-op (same reference).
|
|
45
|
+
// When an earlier duplicate exists, the filter below removes it so the
|
|
46
|
+
// entry moves to the end (recency wins).
|
|
47
|
+
const lastIndex = history.lastIndexOf(trimmed);
|
|
48
|
+
if (lastIndex !== -1 && lastIndex === history.length - 1 && history.indexOf(trimmed) === lastIndex) {
|
|
49
|
+
return history;
|
|
50
|
+
}
|
|
51
|
+
// Remove any earlier occurrence (recency wins).
|
|
52
|
+
const filtered = history.filter(item => item !== trimmed);
|
|
53
|
+
filtered.push(trimmed);
|
|
54
|
+
// FIFO: drop oldest entries from the front.
|
|
55
|
+
const cap = Math.max(1, capacity);
|
|
56
|
+
if (filtered.length > cap) {
|
|
57
|
+
return filtered.slice(filtered.length - cap);
|
|
58
|
+
}
|
|
59
|
+
return filtered;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Navigation cursor for walking the history.
|
|
63
|
+
*
|
|
64
|
+
* The cursor is `null` when the user is not navigating (i.e. they are
|
|
65
|
+
* typing a fresh draft). ArrowUp sets it to the last index, then
|
|
66
|
+
* decrements; ArrowDown increments; when it would exceed `history.length
|
|
67
|
+
* - 1`, it returns to `null` (meaning "restore the in-progress draft").
|
|
68
|
+
*
|
|
69
|
+
* @param current - the current cursor (null = not navigating).
|
|
70
|
+
* @param total - the total number of history entries.
|
|
71
|
+
* @param dir - `'up'` (older) or `'down'` (newer).
|
|
72
|
+
* @returns the next cursor, or `null` when navigation falls off the
|
|
73
|
+
* newest end (caller should restore the saved draft).
|
|
74
|
+
*/
|
|
75
|
+
export function nextIndex(current, total, dir) {
|
|
76
|
+
if (total === 0)
|
|
77
|
+
return null;
|
|
78
|
+
if (dir === 'up') {
|
|
79
|
+
if (current === null)
|
|
80
|
+
return total - 1;
|
|
81
|
+
if (current <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
return current - 1;
|
|
84
|
+
}
|
|
85
|
+
// dir === 'down'
|
|
86
|
+
if (current === null)
|
|
87
|
+
return null;
|
|
88
|
+
if (current >= total - 1)
|
|
89
|
+
return null;
|
|
90
|
+
return current + 1;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the history entry at a cursor, or `null` when the cursor is null.
|
|
94
|
+
*
|
|
95
|
+
* @param history - the history array.
|
|
96
|
+
* @param cursor - the navigation cursor.
|
|
97
|
+
* @returns the prompt at the cursor, or `null`.
|
|
98
|
+
*/
|
|
99
|
+
export function entryAt(history, cursor) {
|
|
100
|
+
if (cursor === null)
|
|
101
|
+
return null;
|
|
102
|
+
if (cursor < 0 || cursor >= history.length)
|
|
103
|
+
return null;
|
|
104
|
+
return history[cursor] ?? null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* History store bound to `localStorage`.
|
|
108
|
+
*
|
|
109
|
+
* The store reads once on construction (or on `reload()`) and keeps an
|
|
110
|
+
* in-memory copy. Writes go to both memory and `localStorage` inside a
|
|
111
|
+
* try/catch — a quota exception leaves the in-memory copy authoritative
|
|
112
|
+
* for the rest of the page lifetime. This trades cross-tab consistency
|
|
113
|
+
* for resilience: the store never throws on a write, and the worst case
|
|
114
|
+
* is that a tab keeps its own view until refresh.
|
|
115
|
+
*
|
|
116
|
+
* Cross-tab sync is intentionally NOT implemented: prompt history is
|
|
117
|
+
* append-mostly and a stale read across tabs is harmless (the next
|
|
118
|
+
* append corrects it). Listening to the `storage` event would add
|
|
119
|
+
* reactivity that the navigation UI does not need.
|
|
120
|
+
*/
|
|
121
|
+
export class HistoryStore {
|
|
122
|
+
capacity;
|
|
123
|
+
items;
|
|
124
|
+
storage;
|
|
125
|
+
key;
|
|
126
|
+
/**
|
|
127
|
+
* @param capacity - maximum entries to retain (FIFO).
|
|
128
|
+
* @param storage - the storage backend (defaults to `localStorage` when available).
|
|
129
|
+
* @param key - the storage key (defaults to {@link STORAGE_KEY}).
|
|
130
|
+
*/
|
|
131
|
+
constructor(capacity = DEFAULT_CAPACITY, storage, key = STORAGE_KEY) {
|
|
132
|
+
this.capacity = capacity;
|
|
133
|
+
this.storage = storage ?? safeLocalStorage();
|
|
134
|
+
this.key = key;
|
|
135
|
+
this.items = this.readFromStorage();
|
|
136
|
+
}
|
|
137
|
+
/** Current history snapshot (newest at end). */
|
|
138
|
+
get list() {
|
|
139
|
+
return this.items;
|
|
140
|
+
}
|
|
141
|
+
/** Number of entries currently stored. */
|
|
142
|
+
get length() {
|
|
143
|
+
return this.items.length;
|
|
144
|
+
}
|
|
145
|
+
/** Reload from storage (e.g. after a suspected external edit). Truncates to the current capacity. */
|
|
146
|
+
reload() {
|
|
147
|
+
const loaded = this.readFromStorage();
|
|
148
|
+
const cap = Math.max(1, this.capacity);
|
|
149
|
+
this.items = loaded.length > cap ? loaded.slice(loaded.length - cap) : loaded;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Append a prompt and persist. See {@link appendHistory} for rules.
|
|
153
|
+
* @returns the new history snapshot.
|
|
154
|
+
*/
|
|
155
|
+
append(prompt) {
|
|
156
|
+
this.items = appendHistory(this.items, prompt, this.capacity);
|
|
157
|
+
this.writeToStorage();
|
|
158
|
+
return this.items;
|
|
159
|
+
}
|
|
160
|
+
/** Clear all history (used by tests and a future "clear" UI). */
|
|
161
|
+
clear() {
|
|
162
|
+
this.items = [];
|
|
163
|
+
this.writeToStorage();
|
|
164
|
+
}
|
|
165
|
+
readFromStorage() {
|
|
166
|
+
if (this.storage === null)
|
|
167
|
+
return [];
|
|
168
|
+
try {
|
|
169
|
+
const raw = this.storage.getItem(this.key);
|
|
170
|
+
if (raw === null)
|
|
171
|
+
return [];
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
if (!Array.isArray(parsed))
|
|
174
|
+
return [];
|
|
175
|
+
return parsed.filter((item) => typeof item === 'string');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
writeToStorage() {
|
|
182
|
+
if (this.storage === null)
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
this.storage.setItem(this.key, JSON.stringify(this.items));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Quota exceeded, private mode, or disabled storage: keep the
|
|
189
|
+
// in-memory copy authoritative for the rest of the page lifetime.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Safe accessor for `localStorage` that returns null on any failure. */
|
|
194
|
+
function safeLocalStorage() {
|
|
195
|
+
try {
|
|
196
|
+
if (typeof localStorage === 'undefined')
|
|
197
|
+
return null;
|
|
198
|
+
return localStorage;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IME-composition key guard.
|
|
3
|
+
*
|
|
4
|
+
* While a Chinese/Japanese/Korean input method is composing (the user is
|
|
5
|
+
* picking a candidate from the IME window), every pressed key BELONGS to
|
|
6
|
+
* the input method: arrows move the candidate highlight, Enter/Space
|
|
7
|
+
* confirm the composition, Escape cancels it. Page code must not process
|
|
8
|
+
* those keys — a history-navigation handler that calls `preventDefault()`
|
|
9
|
+
* on ArrowUp/ArrowDown during composition would silently break the IME:
|
|
10
|
+
* candidates stop responding, the composition gets torn apart, and only
|
|
11
|
+
* bare letters come out.
|
|
12
|
+
*
|
|
13
|
+
* The composition signal follows the DSH core convention (InputBar's IME
|
|
14
|
+
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as
|
|
15
|
+
* the legacy signal engines emit without isComposing.
|
|
16
|
+
*
|
|
17
|
+
* @module @huanlin/dsh-plugin-input-history/client/ime
|
|
18
|
+
*/
|
|
19
|
+
/** The pure decision: is this keyboard event part of an IME composition? */
|
|
20
|
+
export declare function isImeComposition(event: {
|
|
21
|
+
isComposing: boolean;
|
|
22
|
+
keyCode: number;
|
|
23
|
+
}): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IME-composition key guard.
|
|
3
|
+
*
|
|
4
|
+
* While a Chinese/Japanese/Korean input method is composing (the user is
|
|
5
|
+
* picking a candidate from the IME window), every pressed key BELONGS to
|
|
6
|
+
* the input method: arrows move the candidate highlight, Enter/Space
|
|
7
|
+
* confirm the composition, Escape cancels it. Page code must not process
|
|
8
|
+
* those keys — a history-navigation handler that calls `preventDefault()`
|
|
9
|
+
* on ArrowUp/ArrowDown during composition would silently break the IME:
|
|
10
|
+
* candidates stop responding, the composition gets torn apart, and only
|
|
11
|
+
* bare letters come out.
|
|
12
|
+
*
|
|
13
|
+
* The composition signal follows the DSH core convention (InputBar's IME
|
|
14
|
+
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as
|
|
15
|
+
* the legacy signal engines emit without isComposing.
|
|
16
|
+
*
|
|
17
|
+
* @module @huanlin/dsh-plugin-input-history/client/ime
|
|
18
|
+
*/
|
|
19
|
+
/** The pure decision: is this keyboard event part of an IME composition? */
|
|
20
|
+
export function isImeComposition(event) {
|
|
21
|
+
return event.isComposing || event.keyCode === 229;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-input-history — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two registrations:
|
|
5
|
+
* - `conversation.composer.dock` list slot (id `dsh-plugin-input-history`,
|
|
6
|
+
* order 100) — renders an invisible anchor that collects history from
|
|
7
|
+
* `session.nodes` every render. The dock is session-scoped; DSH treats
|
|
8
|
+
* blank sessions as "hero" and suppresses the dock, so history
|
|
9
|
+
* collection only runs in active sessions. That is fine: the first
|
|
10
|
+
* message in a blank session is collected after the session becomes
|
|
11
|
+
* active (the message makes it non-blank).
|
|
12
|
+
* - A document-level `keydown` listener attached in `apply` (NOT in the
|
|
13
|
+
* dock component) — this ensures the listener is always active,
|
|
14
|
+
* including in hero/blank mode where the dock is suppressed. The
|
|
15
|
+
* listener uses the native `value` setter + `dispatchEvent('input')`
|
|
16
|
+
* to feed history text into the textarea, which triggers InputBar's
|
|
17
|
+
* `onChange` → `keyboard.setDraft` — the same path the user's typing
|
|
18
|
+
* takes.
|
|
19
|
+
*
|
|
20
|
+
* History is collected from `user` and `steering` conversation nodes as
|
|
21
|
+
* they appear in any session's `ConversationSnapshot`, persisted to
|
|
22
|
+
* `localStorage` (FIFO, 500 entries), and shared across all sessions
|
|
23
|
+
* in the same browser profile.
|
|
24
|
+
*
|
|
25
|
+
* @module @huanlin/dsh-plugin-input-history/client
|
|
26
|
+
*/
|
|
27
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
28
|
+
import { type InputHistoryKey } from './locales.ts';
|
|
29
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
30
|
+
interface LocaleNamespaceMap {
|
|
31
|
+
/** The dock's aria-label + future settings row copy. */
|
|
32
|
+
'dsh-plugin-input-history': InputHistoryKey;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Required services: slots + locale. */
|
|
36
|
+
export declare const inject: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Client plugin body: register the dock + attach the keydown listener.
|
|
39
|
+
*
|
|
40
|
+
* @param ctx - client root context.
|
|
41
|
+
*/
|
|
42
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-input-history — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two registrations:
|
|
5
|
+
* - `conversation.composer.dock` list slot (id `dsh-plugin-input-history`,
|
|
6
|
+
* order 100) — renders an invisible anchor that collects history from
|
|
7
|
+
* `session.nodes` every render. The dock is session-scoped; DSH treats
|
|
8
|
+
* blank sessions as "hero" and suppresses the dock, so history
|
|
9
|
+
* collection only runs in active sessions. That is fine: the first
|
|
10
|
+
* message in a blank session is collected after the session becomes
|
|
11
|
+
* active (the message makes it non-blank).
|
|
12
|
+
* - A document-level `keydown` listener attached in `apply` (NOT in the
|
|
13
|
+
* dock component) — this ensures the listener is always active,
|
|
14
|
+
* including in hero/blank mode where the dock is suppressed. The
|
|
15
|
+
* listener uses the native `value` setter + `dispatchEvent('input')`
|
|
16
|
+
* to feed history text into the textarea, which triggers InputBar's
|
|
17
|
+
* `onChange` → `keyboard.setDraft` — the same path the user's typing
|
|
18
|
+
* takes.
|
|
19
|
+
*
|
|
20
|
+
* History is collected from `user` and `steering` conversation nodes as
|
|
21
|
+
* they appear in any session's `ConversationSnapshot`, persisted to
|
|
22
|
+
* `localStorage` (FIFO, 500 entries), and shared across all sessions
|
|
23
|
+
* in the same browser profile.
|
|
24
|
+
*
|
|
25
|
+
* @module @huanlin/dsh-plugin-input-history/client
|
|
26
|
+
*/
|
|
27
|
+
import { HistoryDock, getHistoryStore } from "./HistoryDock.js";
|
|
28
|
+
import { isImeComposition } from "./ime.js";
|
|
29
|
+
import { cursorLineInfo, findComposerTextarea } from "./dom.js";
|
|
30
|
+
import { nextIndex, entryAt } from "./history.js";
|
|
31
|
+
import { en, NS, zh } from "./locales.js";
|
|
32
|
+
/** Required services: slots + locale. */
|
|
33
|
+
export const inject = ['slots', 'locale'];
|
|
34
|
+
/**
|
|
35
|
+
* Navigation cursor + saved draft for the keydown listener. Module-scoped
|
|
36
|
+
* because the listener is attached once in `apply` and must persist across
|
|
37
|
+
* dock mount/unmount cycles.
|
|
38
|
+
*/
|
|
39
|
+
let navCursor = null;
|
|
40
|
+
let savedDraft = null;
|
|
41
|
+
/**
|
|
42
|
+
* Client plugin body: register the dock + attach the keydown listener.
|
|
43
|
+
*
|
|
44
|
+
* @param ctx - client root context.
|
|
45
|
+
*/
|
|
46
|
+
export function apply(ctx) {
|
|
47
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin-input-history: dictionaries');
|
|
48
|
+
// The dock collects history from session.nodes. It is session-scoped;
|
|
49
|
+
// in hero/blank mode it is suppressed, but the keydown listener below
|
|
50
|
+
// still works (it reads from the module-scope HistoryStore, which
|
|
51
|
+
// persists across dock mount/unmount cycles via localStorage).
|
|
52
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
53
|
+
name: 'conversation.composer.dock',
|
|
54
|
+
id: 'dsh-plugin-input-history',
|
|
55
|
+
order: 100,
|
|
56
|
+
locale: NS,
|
|
57
|
+
}, HistoryDock));
|
|
58
|
+
// Attach the document-level keydown listener. This lives in `apply`
|
|
59
|
+
// (not in the dock component) so it stays active even when the dock is
|
|
60
|
+
// suppressed (hero/blank sessions — ConversationRoot.tsx:79-80 treats
|
|
61
|
+
// blank sessions as hero, and line 156 skips the dock render).
|
|
62
|
+
ctx.effect(() => {
|
|
63
|
+
if (typeof document === 'undefined')
|
|
64
|
+
return () => { };
|
|
65
|
+
const handler = (event) => {
|
|
66
|
+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
|
|
67
|
+
return;
|
|
68
|
+
if (isImeComposition(event))
|
|
69
|
+
return;
|
|
70
|
+
if (event.defaultPrevented)
|
|
71
|
+
return;
|
|
72
|
+
const textarea = findComposerTextarea(event.target);
|
|
73
|
+
if (textarea === null)
|
|
74
|
+
return;
|
|
75
|
+
if (event.target !== textarea)
|
|
76
|
+
return;
|
|
77
|
+
// Skip if the textarea is readOnly or disabled — hero mode's
|
|
78
|
+
// workspace-picker trigger, or a submitting machine phase.
|
|
79
|
+
if (textarea.readOnly || textarea.disabled)
|
|
80
|
+
return;
|
|
81
|
+
const store = getHistoryStore();
|
|
82
|
+
const history = store.list;
|
|
83
|
+
// Multi-line boundary check.
|
|
84
|
+
const info = cursorLineInfo(textarea.value, textarea.selectionStart, textarea.selectionEnd);
|
|
85
|
+
if (event.key === 'ArrowUp' && !info.atFirstLine)
|
|
86
|
+
return;
|
|
87
|
+
if (event.key === 'ArrowDown' && !info.atLastLine)
|
|
88
|
+
return;
|
|
89
|
+
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
|
90
|
+
const next = nextIndex(navCursor, history.length, dir);
|
|
91
|
+
// Down off the newest end: restore the saved draft (if any).
|
|
92
|
+
if (next === null) {
|
|
93
|
+
const saved = savedDraft;
|
|
94
|
+
navCursor = null;
|
|
95
|
+
if (saved !== null) {
|
|
96
|
+
setNativeTextareaValue(textarea, saved);
|
|
97
|
+
savedDraft = null;
|
|
98
|
+
}
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Entering history: save the current draft the first time we
|
|
103
|
+
// navigate away from "not navigating".
|
|
104
|
+
if (navCursor === null && savedDraft === null) {
|
|
105
|
+
savedDraft = textarea.value;
|
|
106
|
+
}
|
|
107
|
+
const entry = entryAt(history, next);
|
|
108
|
+
if (entry === null)
|
|
109
|
+
return;
|
|
110
|
+
navCursor = next;
|
|
111
|
+
setNativeTextareaValue(textarea, entry);
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
};
|
|
114
|
+
document.addEventListener('keydown', handler, false);
|
|
115
|
+
return () => {
|
|
116
|
+
document.removeEventListener('keydown', handler, false);
|
|
117
|
+
};
|
|
118
|
+
}, 'dsh-plugin-input-history: keydown listener');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Set the textarea value via the native prototype setter and dispatch an
|
|
122
|
+
* `input` event so React's controlled-component onChange fires.
|
|
123
|
+
*
|
|
124
|
+
* React 18 tracks the textarea's value internally; directly assigning
|
|
125
|
+
* `textarea.value = x` does NOT trigger React's onChange because React's
|
|
126
|
+
* value tracker compares against its last-seen value. Using the native
|
|
127
|
+
* prototype setter bypasses React's tracker, and the dispatched `input`
|
|
128
|
+
* event makes React detect the change and run InputBar's `onChange` →
|
|
129
|
+
* `keyboard.setDraft(next)`. This is the same technique used by
|
|
130
|
+
* browser automation libraries (Playwright, Testing Library) to simulate
|
|
131
|
+
* user typing in React controlled inputs.
|
|
132
|
+
*
|
|
133
|
+
* @param textarea - the target textarea element.
|
|
134
|
+
* @param value - the new value to set.
|
|
135
|
+
*/
|
|
136
|
+
function setNativeTextareaValue(textarea, value) {
|
|
137
|
+
const proto = window.HTMLTextAreaElement.prototype;
|
|
138
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
139
|
+
if (descriptor === undefined || descriptor.set === undefined) {
|
|
140
|
+
// Fallback: direct assignment (may not trigger React onChange in
|
|
141
|
+
// all browsers, but better than nothing).
|
|
142
|
+
textarea.value = value;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
descriptor.set.call(textarea, value);
|
|
146
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
147
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale dictionaries for dsh-plugin-input-history.
|
|
3
|
+
*
|
|
4
|
+
* The plugin renders nothing visible — the only user-facing copy is the
|
|
5
|
+
* `aria-label` on the invisible dock anchor (for screen readers) and a
|
|
6
|
+
* future settings row label.
|
|
7
|
+
*
|
|
8
|
+
* @module @huanlin/dsh-plugin-input-history/client/locales
|
|
9
|
+
*/
|
|
10
|
+
/** All copy keys for the dsh-plugin-input-history namespace. */
|
|
11
|
+
export type InputHistoryKey = 'ariaLabel' | 'restoredDraft' | 'noHistory';
|
|
12
|
+
/** Locale namespace id (matches the cordis.patch.yml plugin id). */
|
|
13
|
+
export declare const NS = "dsh-plugin-input-history";
|
|
14
|
+
/** English dictionary. */
|
|
15
|
+
export declare const en: Record<InputHistoryKey, string>;
|
|
16
|
+
/** Chinese dictionary. */
|
|
17
|
+
export declare const zh: Record<InputHistoryKey, string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale dictionaries for dsh-plugin-input-history.
|
|
3
|
+
*
|
|
4
|
+
* The plugin renders nothing visible — the only user-facing copy is the
|
|
5
|
+
* `aria-label` on the invisible dock anchor (for screen readers) and a
|
|
6
|
+
* future settings row label.
|
|
7
|
+
*
|
|
8
|
+
* @module @huanlin/dsh-plugin-input-history/client/locales
|
|
9
|
+
*/
|
|
10
|
+
/** Locale namespace id (matches the cordis.patch.yml plugin id). */
|
|
11
|
+
export const NS = 'dsh-plugin-input-history';
|
|
12
|
+
/** English dictionary. */
|
|
13
|
+
export const en = {
|
|
14
|
+
ariaLabel: 'Prompt history navigation (ArrowUp/ArrowDown)',
|
|
15
|
+
restoredDraft: 'Restored in-progress draft',
|
|
16
|
+
noHistory: 'No prompt history yet',
|
|
17
|
+
};
|
|
18
|
+
/** Chinese dictionary. */
|
|
19
|
+
export const zh = {
|
|
20
|
+
ariaLabel: '提示词历史导航(上/下方向键)',
|
|
21
|
+
restoredDraft: '已恢复正在编辑的草稿',
|
|
22
|
+
noHistory: '暂无提示词历史',
|
|
23
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-input-history — host plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Client-only plugin: the host half has no runtime work. The browser half
|
|
5
|
+
* (`./client`) registers an invisible entry in `conversation.composer.dock`
|
|
6
|
+
* that attaches a document-level `keydown` listener and implements
|
|
7
|
+
* terminal-style prompt history navigation (ArrowUp/ArrowDown) over the
|
|
8
|
+
* composer textarea.
|
|
9
|
+
*
|
|
10
|
+
* History is collected from `user` and `steering` conversation nodes as
|
|
11
|
+
* they appear in any session's `ConversationSnapshot`, persisted to
|
|
12
|
+
* `localStorage` (FIFO, capacity 500), and shared across all sessions
|
|
13
|
+
* in the same browser profile.
|
|
14
|
+
*
|
|
15
|
+
* @module @huanlin/dsh-plugin-input-history
|
|
16
|
+
*/
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
18
|
+
export declare const name = "dsh-plugin-input-history";
|
|
19
|
+
export declare const inject: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Host apply — no-op. The history navigation is a pure client-side UI
|
|
22
|
+
* contribution; no host-side resources are used.
|
|
23
|
+
* @param _ctx - host context (unused).
|
|
24
|
+
*/
|
|
25
|
+
export declare function apply(_ctx: Context): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-input-history — host plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Client-only plugin: the host half has no runtime work. The browser half
|
|
5
|
+
* (`./client`) registers an invisible entry in `conversation.composer.dock`
|
|
6
|
+
* that attaches a document-level `keydown` listener and implements
|
|
7
|
+
* terminal-style prompt history navigation (ArrowUp/ArrowDown) over the
|
|
8
|
+
* composer textarea.
|
|
9
|
+
*
|
|
10
|
+
* History is collected from `user` and `steering` conversation nodes as
|
|
11
|
+
* they appear in any session's `ConversationSnapshot`, persisted to
|
|
12
|
+
* `localStorage` (FIFO, capacity 500), and shared across all sessions
|
|
13
|
+
* in the same browser profile.
|
|
14
|
+
*
|
|
15
|
+
* @module @huanlin/dsh-plugin-input-history
|
|
16
|
+
*/
|
|
17
|
+
export const name = 'dsh-plugin-input-history';
|
|
18
|
+
export const inject = [];
|
|
19
|
+
/**
|
|
20
|
+
* Host apply — no-op. The history navigation is a pure client-side UI
|
|
21
|
+
* contribution; no host-side resources are used.
|
|
22
|
+
* @param _ctx - host context (unused).
|
|
23
|
+
*/
|
|
24
|
+
export function apply(_ctx) {
|
|
25
|
+
// Client-only plugin: all work happens in src/client/index.ts.
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@huanlin/dsh-plugin-input-history`.
|
|
3
|
+
*
|
|
4
|
+
* @module @huanlin/dsh-plugin-input-history/invariant
|
|
5
|
+
*/
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
export declare const name = "dsh-plugin-input-history-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
export declare const inject: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Register this package's invariant companion.
|
|
13
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
14
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
15
|
+
*/
|
|
16
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@huanlin/dsh-plugin-input-history`.
|
|
3
|
+
*
|
|
4
|
+
* @module @huanlin/dsh-plugin-input-history/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = '@huanlin/dsh-plugin-input-history';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
export const name = 'dsh-plugin-input-history-invariant';
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
export const inject = ['invariants'];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the single `conversation.composer.dock` slot
|
|
13
|
+
* registration is a registry-owned contribution whose disposal is proven
|
|
14
|
+
* by the HMR-safety spec. The plugin's only mutable state is the
|
|
15
|
+
* localStorage-backed history array, whose lifecycle is bounded by the
|
|
16
|
+
* browser profile (not the cordis fiber) and whose write path is
|
|
17
|
+
* last-writer-wins with try/catch containment.
|
|
18
|
+
*/
|
|
19
|
+
const install = () => { };
|
|
20
|
+
/**
|
|
21
|
+
* Register this package's invariant companion.
|
|
22
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
23
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
24
|
+
*/
|
|
25
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
26
|
+
/* jscpd:ignore-end */
|