dsh-composer-predictions 1.1.2 → 1.2.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/CHANGELOG.md +34 -1
- package/README.md +62 -7
- package/client.js +110 -40
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.2.0] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Settings → General switch to pause both modes, remembered on this device.
|
|
14
|
+
- Accessible announcement of final candidates and keyboard controls.
|
|
15
|
+
- Regression tests for real overlay behavior, split UTF-8 streams and preferences.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Tab accepts only final, normalized candidates, avoiding duplicated draft text
|
|
20
|
+
and prompt echoes while a response is still streaming.
|
|
21
|
+
- A stream without a terminal event clears the partial suggestion.
|
|
22
|
+
- Continuations keep their caret position and hide when there is no room.
|
|
23
|
+
- Joining a continuation no longer doubles a draft's trailing space.
|
|
24
|
+
- A refused editor insertion never falls back to replacing the entire draft.
|
|
25
|
+
- Comparison copy describes the actual differences between prediction plugins.
|
|
26
|
+
|
|
27
|
+
## [1.1.3] - 2026-10-23
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- README leads with what makes this plugin different: caret continuation plus
|
|
32
|
+
keyboard-only candidate cycling, in a comparison table against the other
|
|
33
|
+
composer-prediction plugins.
|
|
34
|
+
- npm description now names both modes.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- A demo GIF in the README showing the continuation ghost and Tab acceptance.
|
|
40
|
+
|
|
9
41
|
## [1.1.2] - 2026-10-23
|
|
10
42
|
|
|
11
43
|
### Changed
|
|
@@ -57,7 +89,8 @@ first registry version is `1.1.0`.)
|
|
|
57
89
|
- Requires a Harness build that provides `conversation.input.overlay`,
|
|
58
90
|
`ctx.llm`, and `sessions.deriveMessages()`.
|
|
59
91
|
|
|
60
|
-
[Unreleased]: https://github.com/Stijnus/dsh-composer-predictions/compare/v1.1.
|
|
92
|
+
[Unreleased]: https://github.com/Stijnus/dsh-composer-predictions/compare/v1.1.3...HEAD
|
|
93
|
+
[1.1.3]: https://github.com/Stijnus/dsh-composer-predictions/compare/v1.1.2...v1.1.3
|
|
61
94
|
[1.1.2]: https://github.com/Stijnus/dsh-composer-predictions/compare/v1.1.1...v1.1.2
|
|
62
95
|
[1.1.1]: https://github.com/Stijnus/dsh-composer-predictions/compare/v1.1.0...v1.1.1
|
|
63
96
|
[1.1.0]: https://github.com/Stijnus/dsh-composer-predictions/releases/tag/v1.1.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# dsh-composer-predictions
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-composer-predictions)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/topics/dsh-plugin)
|
|
6
|
+
|
|
3
7
|
AI ghost text for the message box in the DeepSeek Harness Web GUI.
|
|
4
8
|
|
|
5
9
|
- **Next message** — after the agent replies, the empty composer shows a
|
|
@@ -10,8 +14,39 @@ AI ghost text for the message box in the DeepSeek Harness Web GUI.
|
|
|
10
14
|
completion without sending anything.
|
|
11
15
|
|
|
12
16
|
Up to three candidates are requested per call. **Alt+Down / Alt+Up** cycle them,
|
|
13
|
-
**Escape** dismisses
|
|
14
|
-
ghost.
|
|
17
|
+
**Escape** dismisses the suggestions, and typing aborts any in-flight request
|
|
18
|
+
and hides the ghost. Streaming text is a preview: `Tab` becomes available when
|
|
19
|
+
the final candidates are ready.
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## How this differs from the other prediction plugins
|
|
24
|
+
|
|
25
|
+
This plugin combines next-message suggestions and draft continuation with
|
|
26
|
+
multiple ghost-text candidates and keyboard cycling: `Tab` accepts into
|
|
27
|
+
an editable draft, `Alt+Down`/`Alt+Up` cycle candidates, and nothing is ever sent
|
|
28
|
+
for you.
|
|
29
|
+
|
|
30
|
+
| | this plugin | [dsh-composer-prediction](https://www.npmjs.com/package/dsh-composer-prediction) | [dsh-input-prediction](https://www.npmjs.com/package/dsh-input-prediction) | [dsh-ghost-prompt](https://www.npmjs.com/package/dsh-ghost-prompt) |
|
|
31
|
+
|---|---|---|---|---|
|
|
32
|
+
| Suggests the next message after a reply | ✅ | ✅ | ✅ | ✅ |
|
|
33
|
+
| Completes the draft while you type, at the caret | ✅ | — | ✅ | — |
|
|
34
|
+
| Several candidates per request | ✅ 3, inline | — | ✅ 1–3 chips | — |
|
|
35
|
+
| Cycle candidates without leaving the keyboard | ✅ `Alt+↓` / `Alt+↑` | — | — (click a chip) | — |
|
|
36
|
+
| Accepts as an editable draft, never sends | ✅ | ✅ | Tab edits the draft; recommendation chips send on click | ✅ |
|
|
37
|
+
| Streamed rendering | ✅ SSE (`/v2`) | not documented | ✅ | ✅ |
|
|
38
|
+
| Recent tool calls and outcomes in the prompt | ✅ | — | — | — |
|
|
39
|
+
| Cache | ✅ per turn + per draft | ✅ | ✅ | not documented |
|
|
40
|
+
|
|
41
|
+
"—" means the feature is not mentioned in that package's README as of
|
|
42
|
+
2026-10-24, not that it is impossible. If all you want is an after-turn
|
|
43
|
+
suggestion, `dsh-composer-prediction` is a perfectly good, more established
|
|
44
|
+
choice — this plugin is for people who also want the caret continuation and
|
|
45
|
+
keyboard-only candidate switching.
|
|
46
|
+
|
|
47
|
+
**Settings → General → Composer predictions** switches both modes on or off.
|
|
48
|
+
The choice is saved in the current browser or desktop window's local storage
|
|
49
|
+
on this device. Other browser origins and devices keep their own preference.
|
|
15
50
|
|
|
16
51
|
## Install
|
|
17
52
|
|
|
@@ -30,13 +65,20 @@ dsh plugin --profile <name> add dsh-composer-predictions
|
|
|
30
65
|
Then restart the Harness (a profile addition is applied at boot). A linked
|
|
31
66
|
source checkout is picked up again when the profile recomposes.
|
|
32
67
|
|
|
68
|
+
Or add the plugin market index to Discover (**Plugins → Discover → Index
|
|
69
|
+
sources**):
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/market-index.json
|
|
73
|
+
```
|
|
74
|
+
|
|
33
75
|
## Use
|
|
34
76
|
|
|
35
77
|
1. Send a message and let the agent finish — a dimmed next-message suggestion
|
|
36
78
|
streams in with a `Tab` keycap.
|
|
37
79
|
2. Or type anything and pause with the caret at the end of the draft — a
|
|
38
80
|
continuation ghost appears right after the caret.
|
|
39
|
-
3. **Tab** turns the current candidate into an editable draft.
|
|
81
|
+
3. When the `Tab` hint appears, **Tab** turns the current candidate into an editable draft.
|
|
40
82
|
**Alt+Down** shows the next candidate, **Alt+Up** the previous one.
|
|
41
83
|
**Escape** dismisses, and typing hides the ghost without sending anything.
|
|
42
84
|
4. Accepting never submits the message; edit it or press Enter yourself.
|
|
@@ -105,7 +147,12 @@ A suggestion longer than 240 characters is cut at a word boundary.
|
|
|
105
147
|
- Predictions are suggestions only; they never run tools or change the Session.
|
|
106
148
|
- Accepting a suggestion and then editing it is a normal draft: it is only sent
|
|
107
149
|
when you submit it.
|
|
108
|
-
- Prediction calls
|
|
150
|
+
- Prediction calls consume model tokens and may incur provider charges even
|
|
151
|
+
though they do not appear in the Session log or its token meter.
|
|
152
|
+
- Only final candidates can be accepted. A disconnected stream is discarded.
|
|
153
|
+
- A continuation is hidden when there is too little room after the caret; it
|
|
154
|
+
never shifts left over your draft.
|
|
155
|
+
- Final suggestions are announced through an accessible status message.
|
|
109
156
|
- Requires a Harness build that provides `conversation.input.overlay`,
|
|
110
157
|
`ctx.llm`, and `sessions.deriveMessages()`.
|
|
111
158
|
|
|
@@ -121,22 +168,30 @@ Probe a running GUI to watch the stream and cache timing:
|
|
|
121
168
|
node tools/live-probe.mjs <sessionId> continue "Can you also"
|
|
122
169
|
```
|
|
123
170
|
|
|
124
|
-
The
|
|
171
|
+
The 30 cases drive both Host endpoints against a fake Session and LLM service:
|
|
125
172
|
prompt contents (including tool activity), candidate normalization and spacing,
|
|
126
173
|
streaming events, per-turn and per-draft caching, route and effort selection,
|
|
127
174
|
failure reporting, and request validation. Client helpers (continuation
|
|
128
175
|
joining, SSE parsing, streaming candidates) are unit-tested through the bundle's
|
|
129
|
-
internal `__testing` export.
|
|
176
|
+
internal `__testing` export. Component regression tests cover streaming acceptance,
|
|
177
|
+
refused insertions, right-edge placement, truncated streams and accessible copy.
|
|
130
178
|
|
|
131
179
|
## Uninstall
|
|
132
180
|
|
|
133
|
-
|
|
181
|
+
Switch **Settings → General → Composer predictions** off to pause suggestions.
|
|
182
|
+
To uninstall, remove the bundle with
|
|
134
183
|
`dsh plugin --profile <name> remove dsh-composer-predictions`.
|
|
135
184
|
|
|
136
185
|
## Changelog
|
|
137
186
|
|
|
138
187
|
See [CHANGELOG.md](https://github.com/Stijnus/dsh-composer-predictions/blob/main/CHANGELOG.md).
|
|
139
188
|
|
|
189
|
+
## Contributing
|
|
190
|
+
|
|
191
|
+
Issues and pull requests are welcome. If this saves you time in the composer, a
|
|
192
|
+
star helps other DSH users find it — plugin catalogs index repositories by the
|
|
193
|
+
`dsh-plugin` topic, and stars decide which ones get surfaced.
|
|
194
|
+
|
|
140
195
|
## License
|
|
141
196
|
|
|
142
197
|
MIT
|
package/client.js
CHANGED
|
@@ -20,6 +20,7 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
factory(require) {
|
|
21
21
|
const React = require('react')
|
|
22
22
|
const h = React.createElement
|
|
23
|
+
const { Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
23
24
|
|
|
24
25
|
const NS = 'composer-predictions'
|
|
25
26
|
const ENDPOINT = '/composer-prediction/v2'
|
|
@@ -49,14 +50,56 @@ window.__ModuleLoader__.load({
|
|
|
49
50
|
label: 'Composer prediction',
|
|
50
51
|
accept: 'Press Tab to accept this suggestion',
|
|
51
52
|
more: 'Alt+Down for the next suggestion',
|
|
53
|
+
enabled: 'Composer predictions',
|
|
54
|
+
description: 'Suggest your next message and continue drafts. Uses your model and tokens. Saved in this browser on this device.',
|
|
55
|
+
ready: 'Suggestion: {text}. Press Tab to accept. Alt+Down or Alt+Up changes candidates. Escape dismisses.',
|
|
52
56
|
},
|
|
53
57
|
zh: {
|
|
54
58
|
label: '输入预测',
|
|
55
59
|
accept: '按 Tab 采纳该建议',
|
|
56
60
|
more: '按 Alt+↓ 查看下一条建议',
|
|
61
|
+
enabled: '输入预测',
|
|
62
|
+
description: '建议下一条消息并续写草稿。会调用模型并消耗 token。设置保存在当前设备的浏览器中。',
|
|
63
|
+
ready: '建议:{text}。按 Tab 采纳,Alt+↓ 或 Alt+↑ 切换,Escape 关闭。',
|
|
57
64
|
},
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
const PREFERENCE_KEY = 'dsh-composer-predictions.enabled'
|
|
68
|
+
let enabled = true
|
|
69
|
+
try {
|
|
70
|
+
enabled = window.localStorage.getItem(PREFERENCE_KEY) !== 'false'
|
|
71
|
+
} catch {
|
|
72
|
+
// Restricted storage keeps the preference in memory for this window.
|
|
73
|
+
}
|
|
74
|
+
const preferenceListeners = new Set()
|
|
75
|
+
const subscribePreference = (listener) => {
|
|
76
|
+
preferenceListeners.add(listener)
|
|
77
|
+
return () => preferenceListeners.delete(listener)
|
|
78
|
+
}
|
|
79
|
+
const readEnabled = () => enabled
|
|
80
|
+
function setEnabled(value) {
|
|
81
|
+
enabled = value
|
|
82
|
+
try {
|
|
83
|
+
window.localStorage.setItem(PREFERENCE_KEY, String(value))
|
|
84
|
+
} catch {
|
|
85
|
+
// The switch still works for this window when storage is unavailable.
|
|
86
|
+
}
|
|
87
|
+
for (const listener of preferenceListeners) listener()
|
|
88
|
+
}
|
|
89
|
+
function useEnabled() {
|
|
90
|
+
return React.useSyncExternalStore(subscribePreference, readEnabled, readEnabled)
|
|
91
|
+
}
|
|
92
|
+
function PredictionsSetting({ t }) {
|
|
93
|
+
const checked = useEnabled()
|
|
94
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, padding: '16px 0', borderBottom: '0.5px solid var(--dsw-alias-border-l2)' } },
|
|
95
|
+
h('div', null,
|
|
96
|
+
h('div', { style: { fontSize: 14, lineHeight: '20px' } }, t('enabled')),
|
|
97
|
+
h('div', { style: { fontSize: 12, lineHeight: '18px', marginTop: 4, color: 'var(--dsw-alias-label-secondary)' } }, t('description')),
|
|
98
|
+
),
|
|
99
|
+
h(Switch, { checked, label: t('enabled'), onChange: setEnabled }),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
60
103
|
const NO_GHOST = { mode: 'next', items: [], index: 0, stream: '', active: false }
|
|
61
104
|
|
|
62
105
|
const NEXT_GHOST_STYLE = {
|
|
@@ -219,12 +262,12 @@ window.__ModuleLoader__.load({
|
|
|
219
262
|
|
|
220
263
|
/**
|
|
221
264
|
* Join a continuation onto the draft without gluing words together.
|
|
222
|
-
*
|
|
223
|
-
* exact draft and caret are known.
|
|
265
|
+
* Avoid repeating a separator already present in the exact draft.
|
|
224
266
|
*/
|
|
225
267
|
function joinContinuation(draft, value) {
|
|
226
268
|
if (draft === '' || value === '') return value
|
|
227
|
-
if (
|
|
269
|
+
if (/\s$/u.test(draft)) return value.replace(/^\s+/u, '')
|
|
270
|
+
if (/^\s/u.test(value)) return value
|
|
228
271
|
const last = draft.slice(-1)
|
|
229
272
|
const first = value.slice(0, 1)
|
|
230
273
|
// CJK text does not use a word separator; never invent one there.
|
|
@@ -273,6 +316,7 @@ window.__ModuleLoader__.load({
|
|
|
273
316
|
const reader = body.getReader()
|
|
274
317
|
const decoder = new TextDecoder()
|
|
275
318
|
let buffer = ''
|
|
319
|
+
let terminal = false
|
|
276
320
|
try {
|
|
277
321
|
for (;;) {
|
|
278
322
|
const { done, value } = await reader.read()
|
|
@@ -288,7 +332,9 @@ window.__ModuleLoader__.load({
|
|
|
288
332
|
const payload = line.slice(5).trim()
|
|
289
333
|
if (payload === '') continue
|
|
290
334
|
try {
|
|
291
|
-
|
|
335
|
+
const event = JSON.parse(payload)
|
|
336
|
+
if (event.type === 'done' || event.type === 'error') terminal = true
|
|
337
|
+
onEvent(event)
|
|
292
338
|
} catch {
|
|
293
339
|
// A malformed frame is ignored; the draft keeps working.
|
|
294
340
|
}
|
|
@@ -296,6 +342,7 @@ window.__ModuleLoader__.load({
|
|
|
296
342
|
boundary = buffer.indexOf('\n\n')
|
|
297
343
|
}
|
|
298
344
|
}
|
|
345
|
+
return terminal
|
|
299
346
|
} finally {
|
|
300
347
|
if (signal.aborted) {
|
|
301
348
|
try {
|
|
@@ -304,11 +351,13 @@ window.__ModuleLoader__.load({
|
|
|
304
351
|
// Cancelling a finished stream is harmless.
|
|
305
352
|
}
|
|
306
353
|
}
|
|
354
|
+
reader.releaseLock()
|
|
307
355
|
}
|
|
308
356
|
}
|
|
309
357
|
|
|
310
358
|
function ComposerPrediction(props) {
|
|
311
359
|
const { sessionId, useInput, useSessionStatus, inputActions, t } = props
|
|
360
|
+
const predictionsEnabled = useEnabled()
|
|
312
361
|
const draft = useInput((state) => (state === undefined ? '' : state.draft || ''))
|
|
313
362
|
const running = useSessionStatus((state) => (state === undefined ? false : state.get(sessionId)?.running === true))
|
|
314
363
|
const [ghost, setGhost] = React.useState(NO_GHOST)
|
|
@@ -376,7 +425,7 @@ window.__ModuleLoader__.load({
|
|
|
376
425
|
return
|
|
377
426
|
}
|
|
378
427
|
|
|
379
|
-
await consumeStream(response.body, controller.signal, (event) => {
|
|
428
|
+
const terminal = await consumeStream(response.body, controller.signal, (event) => {
|
|
380
429
|
if (controller.signal.aborted) return
|
|
381
430
|
if (event.type === 'delta' && typeof event.text === 'string') {
|
|
382
431
|
setGhost((previous) =>
|
|
@@ -393,6 +442,7 @@ window.__ModuleLoader__.load({
|
|
|
393
442
|
settle([])
|
|
394
443
|
}
|
|
395
444
|
})
|
|
445
|
+
if (!terminal) settle([])
|
|
396
446
|
} catch {
|
|
397
447
|
// An aborted or failed prediction is silent: the composer keeps working.
|
|
398
448
|
if (!controller.signal.aborted) settle([])
|
|
@@ -401,6 +451,13 @@ window.__ModuleLoader__.load({
|
|
|
401
451
|
[sessionId],
|
|
402
452
|
)
|
|
403
453
|
|
|
454
|
+
React.useEffect(() => {
|
|
455
|
+
if (predictionsEnabled) return
|
|
456
|
+
abortRef.current?.abort()
|
|
457
|
+
setGhost(NO_GHOST)
|
|
458
|
+
setBox(null)
|
|
459
|
+
}, [predictionsEnabled])
|
|
460
|
+
|
|
404
461
|
// IME composition owns the draft until it commits: no prediction work.
|
|
405
462
|
React.useEffect(() => {
|
|
406
463
|
const onCompositionStart = () => {
|
|
@@ -448,19 +505,19 @@ window.__ModuleLoader__.load({
|
|
|
448
505
|
// `next`: ask once the turn has settled and the composer is empty.
|
|
449
506
|
React.useEffect(() => {
|
|
450
507
|
if (sessionId === undefined || sessionId === null) return undefined
|
|
451
|
-
if (running || draft !== '') return undefined
|
|
508
|
+
if (!predictionsEnabled || running || draft !== '') return undefined
|
|
452
509
|
if (dismissedRef.current !== null && dismissedRef.current.draft === draft) return undefined
|
|
453
510
|
const timer = setTimeout(() => {
|
|
454
511
|
if (composingRef.current) return
|
|
455
512
|
void request('next', '')
|
|
456
513
|
}, SETTLE_MS)
|
|
457
514
|
return () => clearTimeout(timer)
|
|
458
|
-
}, [sessionId, running, draft, request])
|
|
515
|
+
}, [sessionId, running, draft, request, predictionsEnabled])
|
|
459
516
|
|
|
460
517
|
// `continue`: ask after a pause, only with the caret at the draft's end.
|
|
461
518
|
React.useEffect(() => {
|
|
462
519
|
if (sessionId === undefined || sessionId === null) return undefined
|
|
463
|
-
if (draft.trim() === '' || draft.length < CONTINUE_MIN_CHARS) return undefined
|
|
520
|
+
if (!predictionsEnabled || draft.trim() === '' || draft.length < CONTINUE_MIN_CHARS) return undefined
|
|
464
521
|
if (isTriggerToken(draft)) return undefined
|
|
465
522
|
if (dismissedRef.current !== null && dismissedRef.current.draft === draft) return undefined
|
|
466
523
|
const timer = setTimeout(() => {
|
|
@@ -471,12 +528,12 @@ window.__ModuleLoader__.load({
|
|
|
471
528
|
void request('continue', draft.slice(-MAX_DRAFT_CHARS))
|
|
472
529
|
}, TYPE_PAUSE_MS)
|
|
473
530
|
return () => clearTimeout(timer)
|
|
474
|
-
}, [sessionId, draft, request])
|
|
531
|
+
}, [sessionId, draft, request, predictionsEnabled])
|
|
475
532
|
|
|
476
533
|
// A prediction skipped while the page was hidden is retried on return.
|
|
477
534
|
React.useEffect(() => {
|
|
478
535
|
const onVisibility = () => {
|
|
479
|
-
if (document.hidden) return
|
|
536
|
+
if (!predictionsEnabled || document.hidden) return
|
|
480
537
|
if (draft !== '' || running) return
|
|
481
538
|
if (currentRef.current !== '' || dismissedRef.current !== null) return
|
|
482
539
|
if (sessionId === undefined || sessionId === null) return
|
|
@@ -484,7 +541,7 @@ window.__ModuleLoader__.load({
|
|
|
484
541
|
}
|
|
485
542
|
document.addEventListener('visibilitychange', onVisibility)
|
|
486
543
|
return () => document.removeEventListener('visibilitychange', onVisibility)
|
|
487
|
-
}, [draft, running, sessionId, request])
|
|
544
|
+
}, [draft, running, sessionId, request, predictionsEnabled])
|
|
488
545
|
|
|
489
546
|
const rawCandidate = ghost.items.length > 0
|
|
490
547
|
? ghost.items[Math.min(ghost.index, ghost.items.length - 1)] ?? ''
|
|
@@ -494,6 +551,7 @@ window.__ModuleLoader__.load({
|
|
|
494
551
|
const hasCandidate = current !== ''
|
|
495
552
|
|
|
496
553
|
const visible =
|
|
554
|
+
predictionsEnabled &&
|
|
497
555
|
current !== '' &&
|
|
498
556
|
box !== null &&
|
|
499
557
|
(ghost.mode === 'next'
|
|
@@ -575,9 +633,12 @@ window.__ModuleLoader__.load({
|
|
|
575
633
|
? Math.round(lineHeight)
|
|
576
634
|
: FALLBACK_LINE_HEIGHT
|
|
577
635
|
const desired = caret.left - cardRect.left
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
636
|
+
const left = Math.max(0, desired)
|
|
637
|
+
const maxWidth = cardRect.right - (cardRect.left + left) - 16
|
|
638
|
+
if (maxWidth < 48) {
|
|
639
|
+
setBox((previous) => (previous === null ? previous : null))
|
|
640
|
+
return
|
|
641
|
+
}
|
|
581
642
|
const top = caret.top - cardRect.top
|
|
582
643
|
// A caret scrolled above or below the card has no honest place to draw.
|
|
583
644
|
if (top < 0 || top > cardRect.height - 8) {
|
|
@@ -617,22 +678,15 @@ window.__ModuleLoader__.load({
|
|
|
617
678
|
|
|
618
679
|
const accept = () => {
|
|
619
680
|
const value = currentRef.current
|
|
620
|
-
if (value === '') return
|
|
681
|
+
if (value === '' || ghost.active) return
|
|
621
682
|
const expected = ghost.mode === 'continue' ? draft + value : value
|
|
622
683
|
dismissedRef.current = { draft: expected }
|
|
623
684
|
abortRef.current?.abort()
|
|
624
|
-
let applied = false
|
|
625
685
|
try {
|
|
626
|
-
|
|
686
|
+
// A refused insertion must never be bypassed by replacing the draft.
|
|
687
|
+
if (inputActions.insertText(value, inputActions.captureInsertion()) !== true) return
|
|
627
688
|
} catch {
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
if (!applied) {
|
|
631
|
-
try {
|
|
632
|
-
inputActions.setDraft(expected)
|
|
633
|
-
} catch {
|
|
634
|
-
// Leave the draft untouched when the composer refuses the write.
|
|
635
|
-
}
|
|
689
|
+
return
|
|
636
690
|
}
|
|
637
691
|
setGhost(NO_GHOST)
|
|
638
692
|
setBox(null)
|
|
@@ -689,42 +743,58 @@ window.__ModuleLoader__.load({
|
|
|
689
743
|
if (event.key !== 'Tab' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return
|
|
690
744
|
// Tab belongs to the editor only; on composer controls it must keep
|
|
691
745
|
// its normal focus-traversal behavior.
|
|
692
|
-
if (!inEditor) return
|
|
746
|
+
if (!inEditor || ghost.active) return
|
|
747
|
+
if (ghost.mode === 'continue' && caretAtEnd(found.editor, draft) === null) return
|
|
693
748
|
event.preventDefault()
|
|
694
749
|
event.stopPropagation()
|
|
695
750
|
acceptRef.current?.()
|
|
696
751
|
}
|
|
697
752
|
document.addEventListener('keydown', onKeyDown, true)
|
|
698
753
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
699
|
-
}, [visible, draft, ghost.items.length])
|
|
754
|
+
}, [visible, draft, ghost.items.length, ghost.active, ghost.mode])
|
|
700
755
|
|
|
701
756
|
if (!visible || box === null) return null
|
|
702
757
|
|
|
703
758
|
const continuation = ghost.mode === 'continue'
|
|
704
759
|
const style = continuation ? CONTINUE_GHOST_STYLE : NEXT_GHOST_STYLE
|
|
705
|
-
const showKeycap = !continuation || (box.maxWidth ?? 0) >= 96
|
|
760
|
+
const showKeycap = !ghost.active && (!continuation || (box.maxWidth ?? 0) >= 96)
|
|
706
761
|
const showBadge = ghost.items.length > 1
|
|
707
762
|
|
|
708
|
-
return h(
|
|
709
|
-
'
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
763
|
+
return h(React.Fragment, null,
|
|
764
|
+
h('span', { role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true', style: { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap' } }, ghost.active ? '' : t('ready').replace('{text}', current)),
|
|
765
|
+
h('div',
|
|
766
|
+
{
|
|
767
|
+
style: { ...style, ...box },
|
|
768
|
+
'aria-hidden': 'true',
|
|
769
|
+
'data-composer-prediction': ghost.mode,
|
|
770
|
+
title: current,
|
|
771
|
+
},
|
|
772
|
+
h('span', { style: continuation ? CONTINUE_TEXT_STYLE : TEXT_STYLE }, current),
|
|
773
|
+
showBadge ? h('span', { style: BADGE_STYLE, title: t('more') }, `${ghost.index + 1}/${ghost.items.length}`) : null,
|
|
774
|
+
showKeycap ? h('kbd', { style: KEYCAP_STYLE, title: t('accept') }, 'Tab') : null,
|
|
775
|
+
),
|
|
719
776
|
)
|
|
720
777
|
}
|
|
721
778
|
|
|
722
779
|
return {
|
|
723
780
|
inject: ['slots', 'locale'],
|
|
724
|
-
__testing: { joinContinuation, parseSseText, streamingCandidate, cleanLine },
|
|
781
|
+
__testing: { joinContinuation, parseSseText, streamingCandidate, cleanLine, consumeStream, setEnabled, readEnabled },
|
|
725
782
|
apply(ctx) {
|
|
726
783
|
const t = ctx.locale.bind(NS)
|
|
727
784
|
ctx.effect(() => ctx.locale.register(NS, DICTIONARIES))
|
|
785
|
+
ctx.effect(() => {
|
|
786
|
+
const onStorage = (event) => {
|
|
787
|
+
if (event.key !== PREFERENCE_KEY && event.key !== null) return
|
|
788
|
+
enabled = event.newValue !== 'false'
|
|
789
|
+
for (const listener of preferenceListeners) listener()
|
|
790
|
+
}
|
|
791
|
+
window.addEventListener('storage', onStorage)
|
|
792
|
+
return () => window.removeEventListener('storage', onStorage)
|
|
793
|
+
})
|
|
794
|
+
ctx.slots.inject('settings.general.item', () => ctx.slots.register(
|
|
795
|
+
{ name: 'settings.general.item', id: 'composer-predictions', order: 50, label: () => t('enabled') },
|
|
796
|
+
() => h(PredictionsSetting, { t }),
|
|
797
|
+
))
|
|
728
798
|
ctx.slots.inject(SLOT, () =>
|
|
729
799
|
ctx.slots.register(
|
|
730
800
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-predictions",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Tab-completion for the DeepSeek Harness composer: next-message ghost text plus inline continuation of your draft, with keyboard candidate cycling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"scripts": {
|