@huanlin/dsh-plugin-input-history 0.2.0 → 0.3.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 +115 -115
- package/lib/client.js +641 -641
- package/lib/types/client/HistoryDock.d.ts +43 -43
- package/lib/types/client/HistoryDock.js +187 -187
- package/lib/types/client/dom.d.ts +81 -81
- package/lib/types/client/dom.js +142 -142
- package/lib/types/client/index.d.ts +34 -34
- package/lib/types/client/index.js +62 -62
- package/lib/types/index.d.ts +24 -24
- package/lib/types/index.js +25 -25
- package/package.json +6 -6
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HistoryDock — invisible dock entry that collects prompt history and
|
|
3
|
-
* drives terminal-style navigation over the composer.
|
|
4
|
-
*
|
|
5
|
-
* Registers as a `conversation.composer.dock` list entry and renders an
|
|
6
|
-
* `aria-hidden` anchor (zero layout footprint). This component owns both
|
|
7
|
-
* plugin behaviors, because both need per-Session machine faces that only
|
|
8
|
-
* session-scoped slot components receive (rc.1: the dock slot no longer
|
|
9
|
-
* carries an `InputZone` owner — `input` is read through the standard
|
|
10
|
-
* `useInput` selector hook, alongside `useChat`/`inputActions`):
|
|
11
|
-
*
|
|
12
|
-
* - **Collection**: every Chat update re-reads the legacy node slice via
|
|
13
|
-
* `useChat` and appends the latest user/steering text to the shared
|
|
14
|
-
* `HistoryStore` (the store dedupes, so repeated appends are no-ops).
|
|
15
|
-
* - **Navigation**: a capture-phase document `keydown` listener. Capture
|
|
16
|
-
* is required because the composer is a Lexical contenteditable — its
|
|
17
|
-
* keymap moves the caret synchronously in JS on the editable element,
|
|
18
|
-
* so a bubble-phase listener would observe the keystroke only after the
|
|
19
|
-
* caret already moved. The listener intercepts ArrowUp/ArrowDown before
|
|
20
|
-
* Lexical, replaces the draft through `inputActions.setDraft` (the
|
|
21
|
-
* public machine action — no DOM writes), and consumes the event.
|
|
22
|
-
*
|
|
23
|
-
* The dock is session-scoped and DSH suppresses it in hero/blank mode, so
|
|
24
|
-
* neither behavior runs without an active session — and navigation could
|
|
25
|
-
* not run there anyway: the input machine (and `inputActions`) exists only
|
|
26
|
-
* for a current session.
|
|
27
|
-
*
|
|
28
|
-
* @module @huanlin/dsh-plugin-input-history/client/HistoryDock
|
|
29
|
-
*/
|
|
30
|
-
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
31
|
-
import { HistoryStore } from './history.ts';
|
|
32
|
-
/** Full props: dock runtime share (standard kit — rc.1 removed the InputZone owner) + locale seat. */
|
|
33
|
-
type HistoryDockProps = PropsRuntime<'conversation.composer.dock'> & PropsLocale<'dsh-plugin-input-history'>;
|
|
34
|
-
/** Get the shared history store (initializes lazily on first call). */
|
|
35
|
-
export declare function getHistoryStore(): HistoryStore;
|
|
36
|
-
/**
|
|
37
|
-
* Render the invisible history dock entry: collection + navigation.
|
|
38
|
-
*
|
|
39
|
-
* @param props - dock runtime share (standard hooks) + locale seat.
|
|
40
|
-
* @returns an `aria-hidden` anchor with zero layout footprint.
|
|
41
|
-
*/
|
|
42
|
-
export declare function HistoryDock({ useInput, useChat, inputActions, sessionId }: HistoryDockProps): import("react").JSX.Element;
|
|
43
|
-
export {};
|
|
1
|
+
/**
|
|
2
|
+
* HistoryDock — invisible dock entry that collects prompt history and
|
|
3
|
+
* drives terminal-style navigation over the composer.
|
|
4
|
+
*
|
|
5
|
+
* Registers as a `conversation.composer.dock` list entry and renders an
|
|
6
|
+
* `aria-hidden` anchor (zero layout footprint). This component owns both
|
|
7
|
+
* plugin behaviors, because both need per-Session machine faces that only
|
|
8
|
+
* session-scoped slot components receive (rc.1: the dock slot no longer
|
|
9
|
+
* carries an `InputZone` owner — `input` is read through the standard
|
|
10
|
+
* `useInput` selector hook, alongside `useChat`/`inputActions`):
|
|
11
|
+
*
|
|
12
|
+
* - **Collection**: every Chat update re-reads the legacy node slice via
|
|
13
|
+
* `useChat` and appends the latest user/steering text to the shared
|
|
14
|
+
* `HistoryStore` (the store dedupes, so repeated appends are no-ops).
|
|
15
|
+
* - **Navigation**: a capture-phase document `keydown` listener. Capture
|
|
16
|
+
* is required because the composer is a Lexical contenteditable — its
|
|
17
|
+
* keymap moves the caret synchronously in JS on the editable element,
|
|
18
|
+
* so a bubble-phase listener would observe the keystroke only after the
|
|
19
|
+
* caret already moved. The listener intercepts ArrowUp/ArrowDown before
|
|
20
|
+
* Lexical, replaces the draft through `inputActions.setDraft` (the
|
|
21
|
+
* public machine action — no DOM writes), and consumes the event.
|
|
22
|
+
*
|
|
23
|
+
* The dock is session-scoped and DSH suppresses it in hero/blank mode, so
|
|
24
|
+
* neither behavior runs without an active session — and navigation could
|
|
25
|
+
* not run there anyway: the input machine (and `inputActions`) exists only
|
|
26
|
+
* for a current session.
|
|
27
|
+
*
|
|
28
|
+
* @module @huanlin/dsh-plugin-input-history/client/HistoryDock
|
|
29
|
+
*/
|
|
30
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
31
|
+
import { HistoryStore } from './history.ts';
|
|
32
|
+
/** Full props: dock runtime share (standard kit — rc.1 removed the InputZone owner) + locale seat. */
|
|
33
|
+
type HistoryDockProps = PropsRuntime<'conversation.composer.dock'> & PropsLocale<'dsh-plugin-input-history'>;
|
|
34
|
+
/** Get the shared history store (initializes lazily on first call). */
|
|
35
|
+
export declare function getHistoryStore(): HistoryStore;
|
|
36
|
+
/**
|
|
37
|
+
* Render the invisible history dock entry: collection + navigation.
|
|
38
|
+
*
|
|
39
|
+
* @param props - dock runtime share (standard hooks) + locale seat.
|
|
40
|
+
* @returns an `aria-hidden` anchor with zero layout footprint.
|
|
41
|
+
*/
|
|
42
|
+
export declare function HistoryDock({ useInput, useChat, inputActions, sessionId }: HistoryDockProps): import("react").JSX.Element;
|
|
43
|
+
export {};
|
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* HistoryDock — invisible dock entry that collects prompt history and
|
|
4
|
-
* drives terminal-style navigation over the composer.
|
|
5
|
-
*
|
|
6
|
-
* Registers as a `conversation.composer.dock` list entry and renders an
|
|
7
|
-
* `aria-hidden` anchor (zero layout footprint). This component owns both
|
|
8
|
-
* plugin behaviors, because both need per-Session machine faces that only
|
|
9
|
-
* session-scoped slot components receive (rc.1: the dock slot no longer
|
|
10
|
-
* carries an `InputZone` owner — `input` is read through the standard
|
|
11
|
-
* `useInput` selector hook, alongside `useChat`/`inputActions`):
|
|
12
|
-
*
|
|
13
|
-
* - **Collection**: every Chat update re-reads the legacy node slice via
|
|
14
|
-
* `useChat` and appends the latest user/steering text to the shared
|
|
15
|
-
* `HistoryStore` (the store dedupes, so repeated appends are no-ops).
|
|
16
|
-
* - **Navigation**: a capture-phase document `keydown` listener. Capture
|
|
17
|
-
* is required because the composer is a Lexical contenteditable — its
|
|
18
|
-
* keymap moves the caret synchronously in JS on the editable element,
|
|
19
|
-
* so a bubble-phase listener would observe the keystroke only after the
|
|
20
|
-
* caret already moved. The listener intercepts ArrowUp/ArrowDown before
|
|
21
|
-
* Lexical, replaces the draft through `inputActions.setDraft` (the
|
|
22
|
-
* public machine action — no DOM writes), and consumes the event.
|
|
23
|
-
*
|
|
24
|
-
* The dock is session-scoped and DSH suppresses it in hero/blank mode, so
|
|
25
|
-
* neither behavior runs without an active session — and navigation could
|
|
26
|
-
* not run there anyway: the input machine (and `inputActions`) exists only
|
|
27
|
-
* for a current session.
|
|
28
|
-
*
|
|
29
|
-
* @module @huanlin/dsh-plugin-input-history/client/HistoryDock
|
|
30
|
-
*/
|
|
31
|
-
import { useEffect, useMemo, useRef } from 'react';
|
|
32
|
-
import { DEFAULT_CAPACITY, HistoryStore, entryAt, nextIndex } from "./history.js";
|
|
33
|
-
import { caretLineBoundary, findComposerEditable, findTriggerMenu } from "./dom.js";
|
|
34
|
-
import { isImeComposition } from "./ime.js";
|
|
35
|
-
/**
|
|
36
|
-
* Module-scope history store, initialized once on first dock mount.
|
|
37
|
-
* Shared across dock mount/unmount cycles; the underlying data persists
|
|
38
|
-
* in `localStorage`.
|
|
39
|
-
*/
|
|
40
|
-
let historyStore = null;
|
|
41
|
-
/** Get the shared history store (initializes lazily on first call). */
|
|
42
|
-
export function getHistoryStore() {
|
|
43
|
-
if (historyStore === null) {
|
|
44
|
-
historyStore = new HistoryStore(DEFAULT_CAPACITY);
|
|
45
|
-
}
|
|
46
|
-
return historyStore;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Render the invisible history dock entry: collection + navigation.
|
|
50
|
-
*
|
|
51
|
-
* @param props - dock runtime share (standard hooks) + locale seat.
|
|
52
|
-
* @returns an `aria-hidden` anchor with zero layout footprint.
|
|
53
|
-
*/
|
|
54
|
-
export function HistoryDock({ useInput, useChat, inputActions, sessionId }) {
|
|
55
|
-
// Live machine faces for the keydown handler; `input` is read through the
|
|
56
|
-
// standard selector hook (rc.1 dropped the dock slot's InputZone owner).
|
|
57
|
-
const input = useInput(s => s);
|
|
58
|
-
// History collection: the Chat target's legacy node slice (plain
|
|
59
|
-
// ConversationNode list, newest last). The store dedupes, so re-appending
|
|
60
|
-
// an unchanged latest text is a no-op.
|
|
61
|
-
const nodes = useChat(s => s.legacy.nodes);
|
|
62
|
-
const lastText = useMemo(() => latestUserOrSteeringText(nodes), [nodes]);
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (lastText !== null)
|
|
65
|
-
getHistoryStore().append(lastText);
|
|
66
|
-
}, [lastText]);
|
|
67
|
-
// Navigation cursor + saved draft. Reset on session switch: the saved
|
|
68
|
-
// draft belonged to the previous session's composer and must not be
|
|
69
|
-
// restored into the new one.
|
|
70
|
-
const navCursorRef = useRef(null);
|
|
71
|
-
const savedDraftRef = useRef(null);
|
|
72
|
-
const prevSessionRef = useRef(sessionId);
|
|
73
|
-
if (prevSessionRef.current !== sessionId) {
|
|
74
|
-
prevSessionRef.current = sessionId;
|
|
75
|
-
navCursorRef.current = null;
|
|
76
|
-
savedDraftRef.current = null;
|
|
77
|
-
}
|
|
78
|
-
// Live machine faces for the keydown handler; the refs refresh each
|
|
79
|
-
// render so the handler (attached once) always reads current values.
|
|
80
|
-
const inputRef = useRef(input);
|
|
81
|
-
inputRef.current = input;
|
|
82
|
-
const actionsRef = useRef(inputActions);
|
|
83
|
-
actionsRef.current = inputActions;
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (typeof document === 'undefined')
|
|
86
|
-
return undefined;
|
|
87
|
-
const handler = (event) => {
|
|
88
|
-
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
|
|
89
|
-
return;
|
|
90
|
-
if (isImeComposition(event))
|
|
91
|
-
return;
|
|
92
|
-
if (event.defaultPrevented)
|
|
93
|
-
return;
|
|
94
|
-
if (actionsRef.current === undefined || inputRef.current === undefined)
|
|
95
|
-
return;
|
|
96
|
-
// The keystroke must originate inside the composer's editable surface
|
|
97
|
-
// (not the card's buttons or chrome).
|
|
98
|
-
const editable = findComposerEditable(event.target);
|
|
99
|
-
if (editable === null)
|
|
100
|
-
return;
|
|
101
|
-
// Trigger menu open: arrows belong to menu highlight arbitration.
|
|
102
|
-
if (findTriggerMenu(editable) !== null)
|
|
103
|
-
return;
|
|
104
|
-
// Do not interfere with the submit transaction.
|
|
105
|
-
if (inputRef.current.phase !== 'plain')
|
|
106
|
-
return;
|
|
107
|
-
// Multi-line boundary: ArrowUp only on the first visual line,
|
|
108
|
-
// ArrowDown only on the last; no geometry means do not navigate.
|
|
109
|
-
const boundary = caretLineBoundary(editable);
|
|
110
|
-
if (boundary === null)
|
|
111
|
-
return;
|
|
112
|
-
if (event.key === 'ArrowUp' && !boundary.atFirstLine)
|
|
113
|
-
return;
|
|
114
|
-
if (event.key === 'ArrowDown' && !boundary.atLastLine)
|
|
115
|
-
return;
|
|
116
|
-
const history = getHistoryStore().list;
|
|
117
|
-
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
|
118
|
-
const next = nextIndex(navCursorRef.current, history.length, dir);
|
|
119
|
-
// Down off the newest end: restore the saved draft (if any).
|
|
120
|
-
if (next === null) {
|
|
121
|
-
const saved = savedDraftRef.current;
|
|
122
|
-
navCursorRef.current = null;
|
|
123
|
-
if (saved !== null) {
|
|
124
|
-
actionsRef.current.setDraft(saved);
|
|
125
|
-
savedDraftRef.current = null;
|
|
126
|
-
}
|
|
127
|
-
consume(event);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
// Entering history: save the current draft the first time we
|
|
131
|
-
// navigate away from "not navigating".
|
|
132
|
-
if (navCursorRef.current === null && savedDraftRef.current === null) {
|
|
133
|
-
savedDraftRef.current = inputRef.current.draft;
|
|
134
|
-
}
|
|
135
|
-
const entry = entryAt(history, next);
|
|
136
|
-
if (entry === null)
|
|
137
|
-
return;
|
|
138
|
-
navCursorRef.current = next;
|
|
139
|
-
actionsRef.current.setDraft(entry);
|
|
140
|
-
consume(event);
|
|
141
|
-
};
|
|
142
|
-
document.addEventListener('keydown', handler, true);
|
|
143
|
-
return () => {
|
|
144
|
-
document.removeEventListener('keydown', handler, true);
|
|
145
|
-
};
|
|
146
|
-
}, []);
|
|
147
|
-
// `display: none` keeps the anchor out of layout and out of the a11y tree.
|
|
148
|
-
return _jsx("div", { "aria-hidden": true, style: { display: 'none' }, "data-dsh-plugin-input-history": "" });
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Consume a navigated keystroke: `preventDefault` stops the browser's own
|
|
152
|
-
* gesture, `stopPropagation` (capture phase, document level) keeps the
|
|
153
|
-
* event from ever reaching Lexical's editable keydown listener — otherwise
|
|
154
|
-
* the keymap would move the caret after the draft was already replaced.
|
|
155
|
-
*/
|
|
156
|
-
function consume(event) {
|
|
157
|
-
event.preventDefault();
|
|
158
|
-
event.stopPropagation();
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Extract the text of the latest `user` or `steering` node from the Chat
|
|
162
|
-
* target's legacy node list.
|
|
163
|
-
*
|
|
164
|
-
* Returns the concatenated text of all `type: 'text'` content blocks.
|
|
165
|
-
* Returns `null` when no user/steering node is present (e.g. a fresh
|
|
166
|
-
* session with only a system/context message).
|
|
167
|
-
*
|
|
168
|
-
* @param nodes - the Chat snapshot's legacy `nodes` array (newest last).
|
|
169
|
-
*/
|
|
170
|
-
function latestUserOrSteeringText(nodes) {
|
|
171
|
-
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
172
|
-
const node = nodes[i];
|
|
173
|
-
if (node.kind !== 'user' && node.kind !== 'steering')
|
|
174
|
-
continue;
|
|
175
|
-
const content = node.content;
|
|
176
|
-
if (content === undefined)
|
|
177
|
-
continue;
|
|
178
|
-
let text = '';
|
|
179
|
-
for (const block of content) {
|
|
180
|
-
if (block.type === 'text' && typeof block.text === 'string') {
|
|
181
|
-
text += block.text;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return text;
|
|
185
|
-
}
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* HistoryDock — invisible dock entry that collects prompt history and
|
|
4
|
+
* drives terminal-style navigation over the composer.
|
|
5
|
+
*
|
|
6
|
+
* Registers as a `conversation.composer.dock` list entry and renders an
|
|
7
|
+
* `aria-hidden` anchor (zero layout footprint). This component owns both
|
|
8
|
+
* plugin behaviors, because both need per-Session machine faces that only
|
|
9
|
+
* session-scoped slot components receive (rc.1: the dock slot no longer
|
|
10
|
+
* carries an `InputZone` owner — `input` is read through the standard
|
|
11
|
+
* `useInput` selector hook, alongside `useChat`/`inputActions`):
|
|
12
|
+
*
|
|
13
|
+
* - **Collection**: every Chat update re-reads the legacy node slice via
|
|
14
|
+
* `useChat` and appends the latest user/steering text to the shared
|
|
15
|
+
* `HistoryStore` (the store dedupes, so repeated appends are no-ops).
|
|
16
|
+
* - **Navigation**: a capture-phase document `keydown` listener. Capture
|
|
17
|
+
* is required because the composer is a Lexical contenteditable — its
|
|
18
|
+
* keymap moves the caret synchronously in JS on the editable element,
|
|
19
|
+
* so a bubble-phase listener would observe the keystroke only after the
|
|
20
|
+
* caret already moved. The listener intercepts ArrowUp/ArrowDown before
|
|
21
|
+
* Lexical, replaces the draft through `inputActions.setDraft` (the
|
|
22
|
+
* public machine action — no DOM writes), and consumes the event.
|
|
23
|
+
*
|
|
24
|
+
* The dock is session-scoped and DSH suppresses it in hero/blank mode, so
|
|
25
|
+
* neither behavior runs without an active session — and navigation could
|
|
26
|
+
* not run there anyway: the input machine (and `inputActions`) exists only
|
|
27
|
+
* for a current session.
|
|
28
|
+
*
|
|
29
|
+
* @module @huanlin/dsh-plugin-input-history/client/HistoryDock
|
|
30
|
+
*/
|
|
31
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
32
|
+
import { DEFAULT_CAPACITY, HistoryStore, entryAt, nextIndex } from "./history.js";
|
|
33
|
+
import { caretLineBoundary, findComposerEditable, findTriggerMenu } from "./dom.js";
|
|
34
|
+
import { isImeComposition } from "./ime.js";
|
|
35
|
+
/**
|
|
36
|
+
* Module-scope history store, initialized once on first dock mount.
|
|
37
|
+
* Shared across dock mount/unmount cycles; the underlying data persists
|
|
38
|
+
* in `localStorage`.
|
|
39
|
+
*/
|
|
40
|
+
let historyStore = null;
|
|
41
|
+
/** Get the shared history store (initializes lazily on first call). */
|
|
42
|
+
export function getHistoryStore() {
|
|
43
|
+
if (historyStore === null) {
|
|
44
|
+
historyStore = new HistoryStore(DEFAULT_CAPACITY);
|
|
45
|
+
}
|
|
46
|
+
return historyStore;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Render the invisible history dock entry: collection + navigation.
|
|
50
|
+
*
|
|
51
|
+
* @param props - dock runtime share (standard hooks) + locale seat.
|
|
52
|
+
* @returns an `aria-hidden` anchor with zero layout footprint.
|
|
53
|
+
*/
|
|
54
|
+
export function HistoryDock({ useInput, useChat, inputActions, sessionId }) {
|
|
55
|
+
// Live machine faces for the keydown handler; `input` is read through the
|
|
56
|
+
// standard selector hook (rc.1 dropped the dock slot's InputZone owner).
|
|
57
|
+
const input = useInput(s => s);
|
|
58
|
+
// History collection: the Chat target's legacy node slice (plain
|
|
59
|
+
// ConversationNode list, newest last). The store dedupes, so re-appending
|
|
60
|
+
// an unchanged latest text is a no-op.
|
|
61
|
+
const nodes = useChat(s => s.legacy.nodes);
|
|
62
|
+
const lastText = useMemo(() => latestUserOrSteeringText(nodes), [nodes]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (lastText !== null)
|
|
65
|
+
getHistoryStore().append(lastText);
|
|
66
|
+
}, [lastText]);
|
|
67
|
+
// Navigation cursor + saved draft. Reset on session switch: the saved
|
|
68
|
+
// draft belonged to the previous session's composer and must not be
|
|
69
|
+
// restored into the new one.
|
|
70
|
+
const navCursorRef = useRef(null);
|
|
71
|
+
const savedDraftRef = useRef(null);
|
|
72
|
+
const prevSessionRef = useRef(sessionId);
|
|
73
|
+
if (prevSessionRef.current !== sessionId) {
|
|
74
|
+
prevSessionRef.current = sessionId;
|
|
75
|
+
navCursorRef.current = null;
|
|
76
|
+
savedDraftRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
// Live machine faces for the keydown handler; the refs refresh each
|
|
79
|
+
// render so the handler (attached once) always reads current values.
|
|
80
|
+
const inputRef = useRef(input);
|
|
81
|
+
inputRef.current = input;
|
|
82
|
+
const actionsRef = useRef(inputActions);
|
|
83
|
+
actionsRef.current = inputActions;
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (typeof document === 'undefined')
|
|
86
|
+
return undefined;
|
|
87
|
+
const handler = (event) => {
|
|
88
|
+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
|
|
89
|
+
return;
|
|
90
|
+
if (isImeComposition(event))
|
|
91
|
+
return;
|
|
92
|
+
if (event.defaultPrevented)
|
|
93
|
+
return;
|
|
94
|
+
if (actionsRef.current === undefined || inputRef.current === undefined)
|
|
95
|
+
return;
|
|
96
|
+
// The keystroke must originate inside the composer's editable surface
|
|
97
|
+
// (not the card's buttons or chrome).
|
|
98
|
+
const editable = findComposerEditable(event.target);
|
|
99
|
+
if (editable === null)
|
|
100
|
+
return;
|
|
101
|
+
// Trigger menu open: arrows belong to menu highlight arbitration.
|
|
102
|
+
if (findTriggerMenu(editable) !== null)
|
|
103
|
+
return;
|
|
104
|
+
// Do not interfere with the submit transaction.
|
|
105
|
+
if (inputRef.current.phase !== 'plain')
|
|
106
|
+
return;
|
|
107
|
+
// Multi-line boundary: ArrowUp only on the first visual line,
|
|
108
|
+
// ArrowDown only on the last; no geometry means do not navigate.
|
|
109
|
+
const boundary = caretLineBoundary(editable);
|
|
110
|
+
if (boundary === null)
|
|
111
|
+
return;
|
|
112
|
+
if (event.key === 'ArrowUp' && !boundary.atFirstLine)
|
|
113
|
+
return;
|
|
114
|
+
if (event.key === 'ArrowDown' && !boundary.atLastLine)
|
|
115
|
+
return;
|
|
116
|
+
const history = getHistoryStore().list;
|
|
117
|
+
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
|
118
|
+
const next = nextIndex(navCursorRef.current, history.length, dir);
|
|
119
|
+
// Down off the newest end: restore the saved draft (if any).
|
|
120
|
+
if (next === null) {
|
|
121
|
+
const saved = savedDraftRef.current;
|
|
122
|
+
navCursorRef.current = null;
|
|
123
|
+
if (saved !== null) {
|
|
124
|
+
actionsRef.current.setDraft(saved);
|
|
125
|
+
savedDraftRef.current = null;
|
|
126
|
+
}
|
|
127
|
+
consume(event);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Entering history: save the current draft the first time we
|
|
131
|
+
// navigate away from "not navigating".
|
|
132
|
+
if (navCursorRef.current === null && savedDraftRef.current === null) {
|
|
133
|
+
savedDraftRef.current = inputRef.current.draft;
|
|
134
|
+
}
|
|
135
|
+
const entry = entryAt(history, next);
|
|
136
|
+
if (entry === null)
|
|
137
|
+
return;
|
|
138
|
+
navCursorRef.current = next;
|
|
139
|
+
actionsRef.current.setDraft(entry);
|
|
140
|
+
consume(event);
|
|
141
|
+
};
|
|
142
|
+
document.addEventListener('keydown', handler, true);
|
|
143
|
+
return () => {
|
|
144
|
+
document.removeEventListener('keydown', handler, true);
|
|
145
|
+
};
|
|
146
|
+
}, []);
|
|
147
|
+
// `display: none` keeps the anchor out of layout and out of the a11y tree.
|
|
148
|
+
return _jsx("div", { "aria-hidden": true, style: { display: 'none' }, "data-dsh-plugin-input-history": "" });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Consume a navigated keystroke: `preventDefault` stops the browser's own
|
|
152
|
+
* gesture, `stopPropagation` (capture phase, document level) keeps the
|
|
153
|
+
* event from ever reaching Lexical's editable keydown listener — otherwise
|
|
154
|
+
* the keymap would move the caret after the draft was already replaced.
|
|
155
|
+
*/
|
|
156
|
+
function consume(event) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Extract the text of the latest `user` or `steering` node from the Chat
|
|
162
|
+
* target's legacy node list.
|
|
163
|
+
*
|
|
164
|
+
* Returns the concatenated text of all `type: 'text'` content blocks.
|
|
165
|
+
* Returns `null` when no user/steering node is present (e.g. a fresh
|
|
166
|
+
* session with only a system/context message).
|
|
167
|
+
*
|
|
168
|
+
* @param nodes - the Chat snapshot's legacy `nodes` array (newest last).
|
|
169
|
+
*/
|
|
170
|
+
function latestUserOrSteeringText(nodes) {
|
|
171
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
172
|
+
const node = nodes[i];
|
|
173
|
+
if (node.kind !== 'user' && node.kind !== 'steering')
|
|
174
|
+
continue;
|
|
175
|
+
const content = node.content;
|
|
176
|
+
if (content === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
let text = '';
|
|
179
|
+
for (const block of content) {
|
|
180
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
181
|
+
text += block.text;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM helpers for the Lexical composer surface.
|
|
3
|
-
*
|
|
4
|
-
* The DSH composer's text surface is a Lexical-bound contenteditable div,
|
|
5
|
-
* not a textarea: plugins cannot obtain a React ref or a slot-currency
|
|
6
|
-
* handle to it, and writing text goes through `inputActions.setDraft` (the
|
|
7
|
-
* public machine action), not the DOM. What remains DOM-bound is geometry
|
|
8
|
-
* and focus: locating the editable the keystroke targeted, detecting an
|
|
9
|
-
* open trigger menu, and deciding whether the collapsed caret sits on the
|
|
10
|
-
* first/last visual line of a multi-line draft.
|
|
11
|
-
*
|
|
12
|
-
* All markers queried here are internal to `@deepseek-ai/dsh-client-ui-conversation`
|
|
13
|
-
* (`InputBar.tsx` / `ComposerContentEditable.tsx`) or
|
|
14
|
-
* `@deepseek-ai/dsh-client-ui-input-trigger` (`MenuView.tsx`); they are
|
|
15
|
-
* stable but undocumented, and the locators below are the single point to
|
|
16
|
-
* update if upstream changes them.
|
|
17
|
-
*
|
|
18
|
-
* @module @huanlin/dsh-plugin-input-history/client/dom
|
|
19
|
-
*/
|
|
20
|
-
/** Line-boundary decision for a collapsed caret. */
|
|
21
|
-
export interface LineBoundary {
|
|
22
|
-
/** True when the caret is collapsed and on the first visual line. */
|
|
23
|
-
readonly atFirstLine: boolean;
|
|
24
|
-
/** True when the caret is collapsed and on the last visual line. */
|
|
25
|
-
readonly atLastLine: boolean;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Pure decision over caret geometry: where a caret resting at `caretTop`
|
|
29
|
-
* sits relative to the box whose visual line tops are `lineTops` (ascending,
|
|
30
|
-
* one entry per visual line, viewport coordinates).
|
|
31
|
-
*
|
|
32
|
-
* @param caretTop - viewport `top` of the collapsed caret's box.
|
|
33
|
-
* @param lineTops - viewport `top` of each visual line, ascending.
|
|
34
|
-
* @param tolerance - px slop absorbing subpixel rounding between the caret
|
|
35
|
-
* rect and its line's rect.
|
|
36
|
-
* @returns the boundary flags; an empty `lineTops` (empty editable) is
|
|
37
|
-
* treated as a single virtual line, so both flags are true.
|
|
38
|
-
*/
|
|
39
|
-
export declare function boundaryFromLineTops(caretTop: number, lineTops: readonly number[], tolerance: number): LineBoundary;
|
|
40
|
-
/**
|
|
41
|
-
* Locate the DSH composer editable the event targeted.
|
|
42
|
-
*
|
|
43
|
-
* Walks from the event target up to the closest `[data-composer-card]`
|
|
44
|
-
* ancestor, queries the `[data-composer-input]` contenteditable inside it,
|
|
45
|
-
* and confirms the target sits inside that editable (keystrokes on the
|
|
46
|
-
* card's buttons and chrome do not navigate history). Returns `null` when
|
|
47
|
-
* the target is not inside the composer editable.
|
|
48
|
-
*
|
|
49
|
-
* @param from - the event target (or any node inside the composer editable).
|
|
50
|
-
* @returns the editable element, or `null` when not found.
|
|
51
|
-
*/
|
|
52
|
-
export declare function findComposerEditable(from: EventTarget | null): HTMLElement | null;
|
|
53
|
-
/**
|
|
54
|
-
* Detect an open trigger (slash-command / @-mention) menu inside the
|
|
55
|
-
* composer card that owns `editable`.
|
|
56
|
-
*
|
|
57
|
-
* While the menu is open, ArrowUp/ArrowDown move the highlighted row and
|
|
58
|
-
* must not recall history. The menu renders inside the same
|
|
59
|
-
* `[data-composer-card]` as the editable and carries the stable
|
|
60
|
-
* `data-trigger-menu` marker.
|
|
61
|
-
*
|
|
62
|
-
* @param editable - the composer editable element.
|
|
63
|
-
* @returns the menu element, or `null` when no menu is open.
|
|
64
|
-
*/
|
|
65
|
-
export declare function findTriggerMenu(editable: HTMLElement): Element | null;
|
|
66
|
-
/**
|
|
67
|
-
* Decide the collapsed caret's line boundary inside the composer editable.
|
|
68
|
-
*
|
|
69
|
-
* Compares the caret's viewport box against the editable content's visual
|
|
70
|
-
* line boxes (`Range.getClientRects()` yields one rect per line fragment;
|
|
71
|
-
* fragments of the same visual line share a top within subpixel slop, so
|
|
72
|
-
* tops are deduped with a 2px threshold). A non-collapsed selection and a
|
|
73
|
-
* geometry-less environment (headless/jsdom) both return `null`, which the
|
|
74
|
-
* caller must treat as "do not navigate".
|
|
75
|
-
*
|
|
76
|
-
* @param editable - the composer editable element.
|
|
77
|
-
* @param tolerance - px slop between the caret rect and its line rect
|
|
78
|
-
* (defaults to 4px).
|
|
79
|
-
* @returns the boundary flags, or `null` when they cannot be determined.
|
|
80
|
-
*/
|
|
81
|
-
export declare function caretLineBoundary(editable: HTMLElement, tolerance?: number): LineBoundary | null;
|
|
1
|
+
/**
|
|
2
|
+
* DOM helpers for the Lexical composer surface.
|
|
3
|
+
*
|
|
4
|
+
* The DSH composer's text surface is a Lexical-bound contenteditable div,
|
|
5
|
+
* not a textarea: plugins cannot obtain a React ref or a slot-currency
|
|
6
|
+
* handle to it, and writing text goes through `inputActions.setDraft` (the
|
|
7
|
+
* public machine action), not the DOM. What remains DOM-bound is geometry
|
|
8
|
+
* and focus: locating the editable the keystroke targeted, detecting an
|
|
9
|
+
* open trigger menu, and deciding whether the collapsed caret sits on the
|
|
10
|
+
* first/last visual line of a multi-line draft.
|
|
11
|
+
*
|
|
12
|
+
* All markers queried here are internal to `@deepseek-ai/dsh-client-ui-conversation`
|
|
13
|
+
* (`InputBar.tsx` / `ComposerContentEditable.tsx`) or
|
|
14
|
+
* `@deepseek-ai/dsh-client-ui-input-trigger` (`MenuView.tsx`); they are
|
|
15
|
+
* stable but undocumented, and the locators below are the single point to
|
|
16
|
+
* update if upstream changes them.
|
|
17
|
+
*
|
|
18
|
+
* @module @huanlin/dsh-plugin-input-history/client/dom
|
|
19
|
+
*/
|
|
20
|
+
/** Line-boundary decision for a collapsed caret. */
|
|
21
|
+
export interface LineBoundary {
|
|
22
|
+
/** True when the caret is collapsed and on the first visual line. */
|
|
23
|
+
readonly atFirstLine: boolean;
|
|
24
|
+
/** True when the caret is collapsed and on the last visual line. */
|
|
25
|
+
readonly atLastLine: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure decision over caret geometry: where a caret resting at `caretTop`
|
|
29
|
+
* sits relative to the box whose visual line tops are `lineTops` (ascending,
|
|
30
|
+
* one entry per visual line, viewport coordinates).
|
|
31
|
+
*
|
|
32
|
+
* @param caretTop - viewport `top` of the collapsed caret's box.
|
|
33
|
+
* @param lineTops - viewport `top` of each visual line, ascending.
|
|
34
|
+
* @param tolerance - px slop absorbing subpixel rounding between the caret
|
|
35
|
+
* rect and its line's rect.
|
|
36
|
+
* @returns the boundary flags; an empty `lineTops` (empty editable) is
|
|
37
|
+
* treated as a single virtual line, so both flags are true.
|
|
38
|
+
*/
|
|
39
|
+
export declare function boundaryFromLineTops(caretTop: number, lineTops: readonly number[], tolerance: number): LineBoundary;
|
|
40
|
+
/**
|
|
41
|
+
* Locate the DSH composer editable the event targeted.
|
|
42
|
+
*
|
|
43
|
+
* Walks from the event target up to the closest `[data-composer-card]`
|
|
44
|
+
* ancestor, queries the `[data-composer-input]` contenteditable inside it,
|
|
45
|
+
* and confirms the target sits inside that editable (keystrokes on the
|
|
46
|
+
* card's buttons and chrome do not navigate history). Returns `null` when
|
|
47
|
+
* the target is not inside the composer editable.
|
|
48
|
+
*
|
|
49
|
+
* @param from - the event target (or any node inside the composer editable).
|
|
50
|
+
* @returns the editable element, or `null` when not found.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findComposerEditable(from: EventTarget | null): HTMLElement | null;
|
|
53
|
+
/**
|
|
54
|
+
* Detect an open trigger (slash-command / @-mention) menu inside the
|
|
55
|
+
* composer card that owns `editable`.
|
|
56
|
+
*
|
|
57
|
+
* While the menu is open, ArrowUp/ArrowDown move the highlighted row and
|
|
58
|
+
* must not recall history. The menu renders inside the same
|
|
59
|
+
* `[data-composer-card]` as the editable and carries the stable
|
|
60
|
+
* `data-trigger-menu` marker.
|
|
61
|
+
*
|
|
62
|
+
* @param editable - the composer editable element.
|
|
63
|
+
* @returns the menu element, or `null` when no menu is open.
|
|
64
|
+
*/
|
|
65
|
+
export declare function findTriggerMenu(editable: HTMLElement): Element | null;
|
|
66
|
+
/**
|
|
67
|
+
* Decide the collapsed caret's line boundary inside the composer editable.
|
|
68
|
+
*
|
|
69
|
+
* Compares the caret's viewport box against the editable content's visual
|
|
70
|
+
* line boxes (`Range.getClientRects()` yields one rect per line fragment;
|
|
71
|
+
* fragments of the same visual line share a top within subpixel slop, so
|
|
72
|
+
* tops are deduped with a 2px threshold). A non-collapsed selection and a
|
|
73
|
+
* geometry-less environment (headless/jsdom) both return `null`, which the
|
|
74
|
+
* caller must treat as "do not navigate".
|
|
75
|
+
*
|
|
76
|
+
* @param editable - the composer editable element.
|
|
77
|
+
* @param tolerance - px slop between the caret rect and its line rect
|
|
78
|
+
* (defaults to 4px).
|
|
79
|
+
* @returns the boundary flags, or `null` when they cannot be determined.
|
|
80
|
+
*/
|
|
81
|
+
export declare function caretLineBoundary(editable: HTMLElement, tolerance?: number): LineBoundary | null;
|