@lsfcy/dsh-composer-predictions 0.0.0-stage → 0.1.1

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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Composer Prediction",
4
+ "description": "Shows a dimmed suggestion for your next message in the empty composer after a turn; Tab turns it into a draft, Esc ignores it."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "输入框预测",
4
+ "description": "一轮结束后在空输入框里用灰字给出你接下来可能发送的消息,按 Tab 填入草稿,Esc 忽略。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@lsfcy/dsh-composer-predictions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Codex-style composer predictions for DeepSeek Harness: after a turn settles, the empty composer shows a dimmed suggestion of your next message; Tab accepts it.",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "dsh-bundle",
11
+ "composer",
12
+ "prediction",
13
+ "suggestion"
14
+ ],
15
+ "exports": {
16
+ ".": "./index.js",
17
+ "./client": "./client.js",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./icon": "./icon.svg",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "index.js",
24
+ "client.js",
25
+ "cordis.patch.yml",
26
+ "src",
27
+ "build.mjs",
28
+ "locale/*.json",
29
+ "icon.svg",
30
+ "README.md",
31
+ "README.en.md"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "immediately": true,
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-ui-conversation"
42
+ ]
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "@deepseek-ai/schemastery": "^3.18.5"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/lsfcy/dsh-composer-predictions.git"
51
+ },
52
+ "homepage": "https://github.com/lsfcy/dsh-composer-predictions#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/lsfcy/dsh-composer-predictions/issues"
55
+ },
56
+ "scripts": {
57
+ "build": "node build.mjs",
58
+ "test": "node --test test/unit.test.mjs test/bundle.test.mjs test/http.test.mjs"
59
+ }
6
60
  }
package/src/ghost.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Ghost-text layer for the DSH composer.
3
+ *
4
+ * Paints one dimmed line of "predicted next message" inside an EMPTY composer,
5
+ * the way Codex shows composer predictions. Pure DOM: no React, no imports, so
6
+ * the DSH client bundle and the demo pages run the exact same code (build.mjs
7
+ * concatenates this file into client.js).
8
+ *
9
+ * The composer is a Lexical contenteditable (`[data-composer-input]`), not a
10
+ * textarea, and it already ships its own absolutely-positioned placeholder
11
+ * (`[data-composer-placeholder]`) inside the same `position:relative` `.grow`
12
+ * wrapper. The layer therefore measures THAT element on every paint and copies
13
+ * its typography, so it sits exactly where the placeholder sits without any
14
+ * hard-coded inset. A plain textarea (the demo harness) falls back to the
15
+ * target's own border box.
16
+ *
17
+ * The placeholder is hidden with `visibility` rather than `display`: its box
18
+ * (and therefore the geometry we measure) survives, and React re-creating the
19
+ * placeholder after a draft round-trip cannot leave two lines painted at once —
20
+ * each paint re-queries the live node and hides the current one.
21
+ *
22
+ * Contract:
23
+ * const layer = createGhostLayer(target, { onAccept, onDismiss, canIntercept })
24
+ * layer.setText('run the tests') // '' hides
25
+ * layer.getText() // current line ('' when hidden)
26
+ * layer.destroy()
27
+ *
28
+ * Keys while the ghost is showing, captured on the CONTAINER (an ancestor), so
29
+ * the listener runs before Lexical's own root listener on the target:
30
+ * Tab -> accept: hide, then onAccept(text)
31
+ * Escape -> dismiss: hide, then onDismiss()
32
+ * typing -> dismiss; the composer inserts the character as usual
33
+ */
34
+ ;(function (global) {
35
+ 'use strict'
36
+
37
+ var COPIED_PROPERTIES = [
38
+ 'font-family',
39
+ 'font-size',
40
+ 'font-weight',
41
+ 'font-style',
42
+ 'font-variant',
43
+ 'font-stretch',
44
+ 'letter-spacing',
45
+ 'line-height',
46
+ 'text-transform',
47
+ 'text-indent',
48
+ 'tab-size',
49
+ 'direction',
50
+ 'padding-top',
51
+ 'padding-right',
52
+ 'padding-bottom',
53
+ 'padding-left',
54
+ 'border-top-width',
55
+ 'border-right-width',
56
+ 'border-bottom-width',
57
+ 'border-left-width',
58
+ 'box-sizing',
59
+ 'overflow-wrap',
60
+ 'word-break',
61
+ 'text-align',
62
+ ]
63
+
64
+ /** True when the composer holds no text of the user's own. */
65
+ function isEmpty(target) {
66
+ if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') return target.value === ''
67
+ return String(target.textContent || '').replace(/\u200b/g, '').trim() === ''
68
+ }
69
+
70
+ function copyStyle(from, parts) {
71
+ var style = global.getComputedStyle(from)
72
+ for (var index = 0; index < COPIED_PROPERTIES.length; index += 1) {
73
+ var name = COPIED_PROPERTIES[index]
74
+ var value = style.getPropertyValue(name)
75
+ if (value) parts.push(name + ':' + value)
76
+ }
77
+ if (style.color) parts.push('color:' + style.color)
78
+ }
79
+
80
+ function createGhostLayer(target, options) {
81
+ var opts = options || {}
82
+ if (!target || !target.parentElement) return null
83
+
84
+ var container = target.parentElement
85
+ var previousPosition = container.style.position
86
+ if (global.getComputedStyle(container).position === 'static') {
87
+ container.style.position = 'relative'
88
+ }
89
+
90
+ var element = document.createElement('div')
91
+ element.dataset.composerPrediction = 'ghost'
92
+ element.setAttribute('aria-hidden', 'true')
93
+ // Keep the pre-first-paint state out of flow so the composer never grows a
94
+ // stray line before a prediction exists.
95
+ element.style.display = 'none'
96
+ container.appendChild(element)
97
+
98
+ var shown = ''
99
+ var placeholder = null
100
+
101
+ function livePlaceholder() {
102
+ return container.querySelector('[data-composer-placeholder]')
103
+ }
104
+
105
+ /** Hide the CURRENT placeholder. A React remount is handled on every paint. */
106
+ function hidePlaceholder() {
107
+ var next = livePlaceholder()
108
+ if (placeholder !== next) {
109
+ restorePlaceholder()
110
+ placeholder = next
111
+ }
112
+ // DSH never sets an inline visibility on this node, so '' is the honest
113
+ // neutral value. Keeping the box (rather than display:none) is what lets
114
+ // the next paint measure the same rectangle.
115
+ if (next) next.style.visibility = 'hidden'
116
+ }
117
+
118
+ function restorePlaceholder() {
119
+ if (placeholder && placeholder.style) placeholder.style.visibility = ''
120
+ placeholder = null
121
+ }
122
+
123
+ function measure(source) {
124
+ if (source !== target) {
125
+ return {
126
+ left: source.offsetLeft,
127
+ top: source.offsetTop,
128
+ width: source.offsetWidth,
129
+ height: source.offsetHeight,
130
+ }
131
+ }
132
+ var box = target.getBoundingClientRect()
133
+ var frame = container.getBoundingClientRect()
134
+ return {
135
+ left: box.left - frame.left - container.clientLeft + container.scrollLeft,
136
+ top: box.top - frame.top - container.clientTop + container.scrollTop,
137
+ width: box.width,
138
+ height: box.height,
139
+ }
140
+ }
141
+
142
+ function paint() {
143
+ var parts = [
144
+ 'display:block',
145
+ 'position:absolute',
146
+ 'pointer-events:none',
147
+ 'user-select:none',
148
+ 'z-index:4',
149
+ 'margin:0',
150
+ 'overflow:hidden',
151
+ 'text-overflow:ellipsis',
152
+ 'white-space:nowrap',
153
+ 'opacity:.5',
154
+ ]
155
+ var source = livePlaceholder() || target
156
+ copyStyle(source, parts)
157
+ // Single-line ellipsis wins over the placeholder's own wrapping rules.
158
+ parts.push('white-space:nowrap', 'overflow:hidden', 'text-overflow:ellipsis')
159
+ if (source === target) {
160
+ parts.push('border-style:solid', 'border-color:transparent', 'background:transparent')
161
+ }
162
+ var box = measure(source)
163
+ if (box.width > 0) {
164
+ parts.push('left:' + box.left + 'px', 'top:' + box.top + 'px', 'width:' + box.width + 'px')
165
+ if (box.height > 0) parts.push('height:' + box.height + 'px')
166
+ }
167
+ element.style.cssText = parts.join(';')
168
+ }
169
+
170
+ function render() {
171
+ // A non-empty draft always wins: the user is already typing their own message.
172
+ var blocked =
173
+ isEmpty(target) === false ||
174
+ target.getAttribute('aria-disabled') === 'true' ||
175
+ target.getBoundingClientRect().width === 0
176
+ if (!shown || blocked) {
177
+ element.style.display = 'none'
178
+ element.textContent = ''
179
+ restorePlaceholder()
180
+ return
181
+ }
182
+ element.textContent = shown
183
+ paint()
184
+ element.style.display = 'block'
185
+ // Hide only after measuring: the hidden node keeps its box, so the next
186
+ // paint still measures the same rectangle.
187
+ hidePlaceholder()
188
+ }
189
+
190
+ function hide() {
191
+ shown = ''
192
+ render()
193
+ }
194
+
195
+ function canIntercept() {
196
+ return typeof opts.canIntercept === 'function' ? opts.canIntercept() !== false : true
197
+ }
198
+
199
+ function acceptShown() {
200
+ var text = shown
201
+ hide()
202
+ if (opts.onAccept) opts.onAccept(text)
203
+ }
204
+
205
+ function onKeyDown(event) {
206
+ if (!shown || element.style.display === 'none') return
207
+ if (event.isComposing || event.keyCode === 229) return
208
+ if (event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
209
+ // The slash/@ completion menu owns Tab while it is open.
210
+ if (!canIntercept()) return
211
+ event.preventDefault()
212
+ event.stopPropagation()
213
+ acceptShown()
214
+ return
215
+ }
216
+ if (event.key === 'Escape') {
217
+ // A dialog or menu owns Escape; never close it on the user's behalf.
218
+ if (!canIntercept()) return
219
+ event.preventDefault()
220
+ event.stopPropagation()
221
+ hide()
222
+ if (opts.onDismiss) opts.onDismiss()
223
+ return
224
+ }
225
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
226
+ hide()
227
+ if (opts.onDismiss) opts.onDismiss()
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Tab with nothing focused. Switching sessions in DSH leaves focus on
233
+ * <body>; the container listener never sees that key, so the visible
234
+ * suggestion would otherwise be unreachable without clicking the composer
235
+ * first. Only the no-focus state is claimed: a focused control keeps its
236
+ * normal Tab navigation.
237
+ */
238
+ function onDocumentKeyDown(event) {
239
+ if (!shown || element.style.display === 'none') return
240
+ if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return
241
+ if (event.key !== 'Tab' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
242
+ var active = global.document ? global.document.activeElement : null
243
+ if (active !== null && active !== global.document.body && active !== global.document.documentElement) return
244
+ if (!canIntercept()) return
245
+ event.preventDefault()
246
+ event.stopPropagation()
247
+ acceptShown()
248
+ }
249
+
250
+ function onInput() {
251
+ render()
252
+ }
253
+
254
+ function onCompositionStart() {
255
+ if (!shown) return
256
+ hide()
257
+ if (opts.onDismiss) opts.onDismiss()
258
+ }
259
+
260
+ var observer = typeof global.ResizeObserver === 'function' ? new global.ResizeObserver(render) : null
261
+ if (observer) observer.observe(target)
262
+ // The placeholder is a React child: a draft round-trip replaces the node.
263
+ // Re-hide the live one instead of trusting the node captured at attach time.
264
+ var mutations =
265
+ typeof global.MutationObserver === 'function'
266
+ ? new global.MutationObserver(function () {
267
+ if (shown) render()
268
+ })
269
+ : null
270
+ if (mutations) mutations.observe(container, { childList: true })
271
+ global.addEventListener('resize', render)
272
+ // Capture on the ancestor: for listeners registered on the SAME node as
273
+ // Lexical's, capture and bubble listeners both run in registration order,
274
+ // so the ancestor is the only place that reliably runs first.
275
+ container.addEventListener('keydown', onKeyDown, true)
276
+ container.addEventListener('input', onInput, true)
277
+ container.addEventListener('compositionstart', onCompositionStart, true)
278
+ target.addEventListener('focus', render)
279
+ if (global.document) global.document.addEventListener('keydown', onDocumentKeyDown, true)
280
+
281
+ return {
282
+ element: element,
283
+ setText: function (text) {
284
+ shown = String(text == null ? '' : text).replace(/\s*\n+\s*/g, ' ').trim()
285
+ render()
286
+ },
287
+ getText: function () {
288
+ return shown
289
+ },
290
+ isShowing: function () {
291
+ return shown !== '' && element.style.display !== 'none'
292
+ },
293
+ hide: hide,
294
+ destroy: function () {
295
+ restorePlaceholder()
296
+ if (observer) observer.disconnect()
297
+ if (mutations) mutations.disconnect()
298
+ global.removeEventListener('resize', render)
299
+ container.removeEventListener('keydown', onKeyDown, true)
300
+ container.removeEventListener('input', onInput, true)
301
+ container.removeEventListener('compositionstart', onCompositionStart, true)
302
+ target.removeEventListener('focus', render)
303
+ if (global.document) global.document.removeEventListener('keydown', onDocumentKeyDown, true)
304
+ element.remove()
305
+ container.style.position = previousPosition
306
+ },
307
+ }
308
+ }
309
+
310
+ if (typeof module === 'object' && module && module.exports) {
311
+ module.exports.createGhostLayer = createGhostLayer
312
+ }
313
+ // Also reachable as a global so the standalone demo pages can load this file
314
+ // with a plain <script> and exercise the very same implementation.
315
+ global.createComposerGhost = createGhostLayer
316
+ global.composerGhostIsEmpty = isEmpty
317
+ })(typeof globalThis === 'undefined' ? this : globalThis)