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 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.2...HEAD
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
+ [![npm](https://img.shields.io/npm/v/dsh-composer-predictions)](https://www.npmjs.com/package/dsh-composer-predictions)
4
+ [![license](https://img.shields.io/npm/l/dsh-composer-predictions)](LICENSE)
5
+ [![topic](https://img.shields.io/badge/topic-dsh--plugin-blue)](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 one, and typing aborts any in-flight request and hides the
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
+ ![Typing a draft, the continuation ghost appearing, and Tab accepting it](https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/docs/demo.gif)
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 do not appear in the Session log or its token meter.
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 22 cases drive both Host endpoints against a fake Session and LLM service:
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
- Disable **Composer predictions** in **Plugins → Installed**, or remove it with
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
- * The Host trims candidate whitespace, so spacing is decided here where the
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 (/^\s/u.test(value) || /\s$/u.test(draft)) return value
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
- onEvent(JSON.parse(payload))
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 maxLeft = Math.max(0, cardRect.width - 160)
579
- const left = Math.max(0, Math.min(desired, maxLeft))
580
- const maxWidth = Math.max(80, cardRect.right - (cardRect.left + left) - 16)
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
- applied = inputActions.insertText(value, inputActions.captureInsertion()) === true
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
- applied = false
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
- 'div',
710
- {
711
- style: { ...style, ...box },
712
- 'aria-hidden': 'true',
713
- 'data-composer-prediction': ghost.mode,
714
- title: current,
715
- },
716
- h('span', { style: continuation ? CONTINUE_TEXT_STYLE : TEXT_STYLE }, current),
717
- showBadge ? h('span', { style: BADGE_STYLE, title: t('more') }, `${ghost.index + 1}/${ghost.items.length}`) : null,
718
- showKeycap ? h('kbd', { style: KEYCAP_STYLE, title: t('accept') }, 'Tab') : null,
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.1.2",
4
- "description": "AI follow-up and inline continuation ghost text for the DeepSeek Harness composer: streamed candidates, Tab to accept, Alt+Down to cycle",
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": {