@huanlin/dsh-plugin-input-history 0.1.2 → 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.
@@ -1,57 +1,81 @@
1
- /**
2
- * DOM helpers for the composer textarea.
3
- *
4
- * The DSH InputBar's textarea is not exposed through any public API —
5
- * plugins cannot obtain a React ref or a slot-currency handle to it.
6
- * The two operations this plugin needs (locate the textarea, decide
7
- * whether the caret is on the first/last line of a multi-line draft) are
8
- * pure functions over DOM and string state, kept here for unit testing.
9
- *
10
- * The locator queries the stable (but undocumented) `data-composer-card`
11
- * attribute on the composer card root (`InputBar.tsx:629`) and returns
12
- * the descendant `<textarea>`. The attribute is internal to
13
- * `@deepseek-ai/dsh-client-ui-conversation` and may change across
14
- * upstream versions; the locator is the single point to update.
15
- *
16
- * @module @huanlin/dsh-plugin-input-history/client/dom
17
- */
18
- /** Caret line information for a multi-line textarea value. */
19
- export interface CursorLineInfo {
20
- /** 0-based index of the line the caret is on. */
21
- readonly currentLine: number;
22
- /** Total number of lines in the value (>= 1). */
23
- readonly totalLines: number;
24
- /** True when the caret is collapsed and on the first line. */
25
- readonly atFirstLine: boolean;
26
- /** True when the caret is collapsed and on the last line. */
27
- readonly atLastLine: boolean;
28
- }
29
- /**
30
- * Compute the caret's line position in a textarea value.
31
- *
32
- * Lines are split on `\n` (the textarea's own line break character). The
33
- * caret must be collapsed (`selectionStart === selectionEnd`) for the
34
- * `atFirstLine` / `atLastLine` flags to be true a non-collapsed
35
- * selection spanning multiple lines should not trigger history navigation.
36
- *
37
- * @param value - the textarea's current value.
38
- * @param selectionStart - the textarea's `selectionStart`.
39
- * @param selectionEnd - the textarea's `selectionEnd` (defaults to `selectionStart`).
40
- * @returns the caret's line information.
41
- */
42
- export declare function cursorLineInfo(value: string, selectionStart: number, selectionEnd?: number): CursorLineInfo;
43
- /**
44
- * Locate the DSH composer textarea in the current document.
45
- *
46
- * Walks from the event target up to find the closest `[data-composer-card]`
47
- * ancestor, then queries the descendant `<textarea>` inside it. Returns
48
- * `null` when the target is not inside the composer card (e.g. the user
49
- * is typing in another input or the textarea is momentarily absent).
50
- *
51
- * When called without an event target, falls back to a document-wide
52
- * query used in tests and ad-hoc probing.
53
- *
54
- * @param from - the event target (or any node inside the composer card).
55
- * @returns the textarea element, or `null` when not found.
56
- */
57
- export declare function findComposerTextarea(from?: EventTarget | null): HTMLTextAreaElement | 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;
@@ -1,99 +1,142 @@
1
- /**
2
- * DOM helpers for the composer textarea.
3
- *
4
- * The DSH InputBar's textarea is not exposed through any public API —
5
- * plugins cannot obtain a React ref or a slot-currency handle to it.
6
- * The two operations this plugin needs (locate the textarea, decide
7
- * whether the caret is on the first/last line of a multi-line draft) are
8
- * pure functions over DOM and string state, kept here for unit testing.
9
- *
10
- * The locator queries the stable (but undocumented) `data-composer-card`
11
- * attribute on the composer card root (`InputBar.tsx:629`) and returns
12
- * the descendant `<textarea>`. The attribute is internal to
13
- * `@deepseek-ai/dsh-client-ui-conversation` and may change across
14
- * upstream versions; the locator is the single point to update.
15
- *
16
- * @module @huanlin/dsh-plugin-input-history/client/dom
17
- */
18
- /**
19
- * Compute the caret's line position in a textarea value.
20
- *
21
- * Lines are split on `\n` (the textarea's own line break character). The
22
- * caret must be collapsed (`selectionStart === selectionEnd`) for the
23
- * `atFirstLine` / `atLastLine` flags to be true — a non-collapsed
24
- * selection spanning multiple lines should not trigger history navigation.
25
- *
26
- * @param value - the textarea's current value.
27
- * @param selectionStart - the textarea's `selectionStart`.
28
- * @param selectionEnd - the textarea's `selectionEnd` (defaults to `selectionStart`).
29
- * @returns the caret's line information.
30
- */
31
- export function cursorLineInfo(value, selectionStart, selectionEnd = selectionStart) {
32
- // Swap if reversed (the browser allows selectionStart > selectionEnd when
33
- // the user drags upwards); clamp to value bounds.
34
- const rawStart = Math.min(selectionStart, selectionEnd);
35
- const rawEnd = Math.max(selectionStart, selectionEnd);
36
- const clampedStart = Math.max(0, Math.min(rawStart, value.length));
37
- const clampedEnd = Math.max(clampedStart, Math.min(rawEnd, value.length));
38
- const collapsed = clampedStart === clampedEnd;
39
- const lines = value.split('\n');
40
- const totalLines = lines.length;
41
- let currentLine = 0;
42
- let runningLength = 0;
43
- for (let i = 0; i < totalLines; i++) {
44
- const line = lines[i];
45
- // The caret at position `p` belongs to line `i` if `p` is in
46
- // [runningLength, runningLength + line.length + 1) the `+1` covers
47
- // the position immediately after the line's last character, which is
48
- // still on this line (right before the `\n`). The very end of the
49
- // value (after the last line's last char) belongs to the last line.
50
- const lineEnd = runningLength + line.length;
51
- const isLastLine = i === totalLines - 1;
52
- const upperBound = isLastLine ? lineEnd + 1 : lineEnd + 1; // include the `\n` position
53
- if (clampedStart >= runningLength && clampedStart < upperBound) {
54
- currentLine = i;
55
- break;
56
- }
57
- runningLength = lineEnd + 1; // +1 for the `\n`
58
- }
59
- return {
60
- currentLine,
61
- totalLines,
62
- atFirstLine: collapsed && currentLine === 0,
63
- atLastLine: collapsed && currentLine === totalLines - 1,
64
- };
65
- }
66
- /**
67
- * Locate the DSH composer textarea in the current document.
68
- *
69
- * Walks from the event target up to find the closest `[data-composer-card]`
70
- * ancestor, then queries the descendant `<textarea>` inside it. Returns
71
- * `null` when the target is not inside the composer card (e.g. the user
72
- * is typing in another input or the textarea is momentarily absent).
73
- *
74
- * When called without an event target, falls back to a document-wide
75
- * query used in tests and ad-hoc probing.
76
- *
77
- * @param from - the event target (or any node inside the composer card).
78
- * @returns the textarea element, or `null` when not found.
79
- */
80
- export function findComposerTextarea(from) {
81
- if (typeof document === 'undefined')
82
- return null;
83
- if (from === undefined) {
84
- // No argument: document-wide query.
85
- return document.querySelector('[data-composer-card] textarea');
86
- }
87
- // `null` or an actual target: do NOT fall back to document-wide query.
88
- if (from === null)
89
- return null;
90
- // `closest` is on Element; EventTarget may be a Text node or other
91
- // non-Element node. Narrow with an instanceof check.
92
- const card = from instanceof Element ? from.closest('[data-composer-card]') : null;
93
- if (card !== null) {
94
- const ta = card.querySelector('textarea');
95
- if (ta !== null)
96
- return ta;
97
- }
98
- return null;
99
- }
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
+ /**
21
+ * Pure decision over caret geometry: where a caret resting at `caretTop`
22
+ * sits relative to the box whose visual line tops are `lineTops` (ascending,
23
+ * one entry per visual line, viewport coordinates).
24
+ *
25
+ * @param caretTop - viewport `top` of the collapsed caret's box.
26
+ * @param lineTops - viewport `top` of each visual line, ascending.
27
+ * @param tolerance - px slop absorbing subpixel rounding between the caret
28
+ * rect and its line's rect.
29
+ * @returns the boundary flags; an empty `lineTops` (empty editable) is
30
+ * treated as a single virtual line, so both flags are true.
31
+ */
32
+ export function boundaryFromLineTops(caretTop, lineTops, tolerance) {
33
+ if (lineTops.length === 0)
34
+ return { atFirstLine: true, atLastLine: true };
35
+ return {
36
+ atFirstLine: caretTop <= lineTops[0] + tolerance,
37
+ atLastLine: caretTop >= lineTops[lineTops.length - 1] - tolerance,
38
+ };
39
+ }
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 function findComposerEditable(from) {
53
+ if (typeof document === 'undefined')
54
+ return null;
55
+ if (from === null || !(from instanceof Element))
56
+ return null;
57
+ const card = from.closest('[data-composer-card]');
58
+ if (card === null)
59
+ return null;
60
+ const editable = card.querySelector('[data-composer-input]');
61
+ if (editable === null)
62
+ return null;
63
+ return editable.contains(from) ? editable : null;
64
+ }
65
+ /**
66
+ * Detect an open trigger (slash-command / @-mention) menu inside the
67
+ * composer card that owns `editable`.
68
+ *
69
+ * While the menu is open, ArrowUp/ArrowDown move the highlighted row and
70
+ * must not recall history. The menu renders inside the same
71
+ * `[data-composer-card]` as the editable and carries the stable
72
+ * `data-trigger-menu` marker.
73
+ *
74
+ * @param editable - the composer editable element.
75
+ * @returns the menu element, or `null` when no menu is open.
76
+ */
77
+ export function findTriggerMenu(editable) {
78
+ const card = editable.closest('[data-composer-card]');
79
+ return card === null ? null : card.querySelector('[data-trigger-menu]');
80
+ }
81
+ /**
82
+ * Decide the collapsed caret's line boundary inside the composer editable.
83
+ *
84
+ * Compares the caret's viewport box against the editable content's visual
85
+ * line boxes (`Range.getClientRects()` yields one rect per line fragment;
86
+ * fragments of the same visual line share a top within subpixel slop, so
87
+ * tops are deduped with a 2px threshold). A non-collapsed selection and a
88
+ * geometry-less environment (headless/jsdom) both return `null`, which the
89
+ * caller must treat as "do not navigate".
90
+ *
91
+ * @param editable - the composer editable element.
92
+ * @param tolerance - px slop between the caret rect and its line rect
93
+ * (defaults to 4px).
94
+ * @returns the boundary flags, or `null` when they cannot be determined.
95
+ */
96
+ export function caretLineBoundary(editable, tolerance = 4) {
97
+ const selection = window.getSelection();
98
+ if (selection === null || selection.rangeCount === 0)
99
+ return null;
100
+ if (!selection.isCollapsed)
101
+ return null;
102
+ const caretTop = caretTopOf(selection);
103
+ if (caretTop === null)
104
+ return null;
105
+ const lineTops = contentLineTops(editable);
106
+ if (lineTops === null)
107
+ return null;
108
+ return boundaryFromLineTops(caretTop, lineTops, tolerance);
109
+ }
110
+ /** Viewport `top` of the collapsed caret's box, or `null` when unmeasurable. */
111
+ function caretTopOf(selection) {
112
+ const rects = selection.getRangeAt(0).getClientRects();
113
+ for (let i = 0; i < rects.length; i++) {
114
+ const rect = rects[i];
115
+ if (rect.height === 0 && rect.width === 0)
116
+ continue;
117
+ return rect.top;
118
+ }
119
+ // Some engines report a zero-box collapsed caret; the anchor's element
120
+ // box is the line the caret sits on (the same ruler InputBar's reveal uses).
121
+ const anchor = selection.anchorNode;
122
+ const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement;
123
+ return el === undefined || el === null ? null : el.getBoundingClientRect().top;
124
+ }
125
+ /** Ascending, deduped tops of the editable content's visual lines; `null` without geometry. Empty for an empty editable. */
126
+ function contentLineTops(editable) {
127
+ const range = document.createRange();
128
+ range.selectNodeContents(editable);
129
+ const rects = range.getClientRects();
130
+ const tops = [];
131
+ for (let i = 0; i < rects.length; i++) {
132
+ const rect = rects[i];
133
+ if (rect.height === 0 && rect.width === 0)
134
+ continue;
135
+ const top = rect.top;
136
+ // Rects come in document order; fragments of one visual line differ by
137
+ // subpixel amounts, real lines by a full line height.
138
+ if (tops.length === 0 || Math.abs(top - tops[tops.length - 1]) > 2)
139
+ tops.push(top);
140
+ }
141
+ return tops;
142
+ }
@@ -1,42 +1,34 @@
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;
1
+ /**
2
+ * dsh-plugin-input-history — browser half.
3
+ *
4
+ * One registration: the `conversation.composer.dock` list slot (id
5
+ * `dsh-plugin-input-history`, order 100) mounts the invisible dock entry
6
+ * that owns both plugin behaviors prompt-history collection from the
7
+ * Chat target's user/steering nodes, and the capture-phase document
8
+ * keydown listener that navigates the composer draft through
9
+ * `inputActions.setDraft`. See [HistoryDock.tsx](./HistoryDock.tsx) for
10
+ * the data flow; the dock is session-scoped, so in hero/blank mode the
11
+ * plugin is dormant (no input machine exists there to drive).
12
+ *
13
+ * History is collected from `user` and `steering` chat nodes of any active
14
+ * session, persisted to `localStorage` (FIFO, 500 entries), and shared
15
+ * across all sessions in the same browser profile.
16
+ *
17
+ * @module @huanlin/dsh-plugin-input-history/client
18
+ */
19
+ import type { Context } from '@deepseek-ai/cordis';
20
+ import { type InputHistoryKey } from './locales.ts';
21
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
22
+ interface LocaleNamespaceMap {
23
+ /** The dock's aria-label + future settings row copy. */
24
+ 'dsh-plugin-input-history': InputHistoryKey;
25
+ }
26
+ }
27
+ /** Required services: slots + locale. */
28
+ export declare const inject: string[];
29
+ /**
30
+ * Client plugin body: register the dock + locale dictionaries.
31
+ *
32
+ * @param ctx - client root context.
33
+ */
34
+ export declare function apply(ctx: Context): void;