@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/LICENSE +21 -0
- package/README.en.md +71 -0
- package/README.md +71 -2
- package/build.mjs +26 -0
- package/client.js +1286 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +8 -0
- package/index.js +891 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/ghost.js +317 -0
- package/src/main.js +958 -0
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lsfcy/dsh-composer-predictions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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)
|