dsh-composer-predictions 1.1.3 → 1.2.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +48 -16
  3. package/client.js +110 -40
  4. package/package.json +8 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.1] - 2026-10-25
10
+
11
+ ### Changed
12
+
13
+ - Publish desktop/web installation guidance and Chinese usage instructions on npm.
14
+ - Expand package search terms for ghost text and inline autocomplete.
15
+ - Release verification waits for npm's asynchronous publication to become visible.
16
+ - Validate GitHub Actions trusted publishing with provenance. Runtime behavior is
17
+ unchanged from 1.2.0.
18
+
19
+ ## [1.2.0] - 2026-10-24
20
+
21
+ ### Added
22
+
23
+ - Settings → General switch to pause both modes, remembered on this device.
24
+ - Accessible announcement of final candidates and keyboard controls.
25
+ - Regression tests for real overlay behavior, split UTF-8 streams and preferences.
26
+
27
+ ### Fixed
28
+
29
+ - Tab accepts only final, normalized candidates, avoiding duplicated draft text
30
+ and prompt echoes while a response is still streaming.
31
+ - A stream without a terminal event clears the partial suggestion.
32
+ - Continuations keep their caret position and hide when there is no room.
33
+ - Joining a continuation no longer doubles a draft's trailing space.
34
+ - A refused editor insertion never falls back to replacing the entire draft.
35
+ - Comparison copy describes the actual differences between prediction plugins.
36
+
9
37
  ## [1.1.3] - 2026-10-23
10
38
 
11
39
  ### Changed
package/README.md CHANGED
@@ -4,7 +4,10 @@
4
4
  [![license](https://img.shields.io/npm/l/dsh-composer-predictions)](LICENSE)
5
5
  [![topic](https://img.shields.io/badge/topic-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
6
6
 
7
- AI ghost text for the message box in the DeepSeek Harness Web GUI.
7
+ AI autocomplete and ghost-text suggestions for the composer in **DeepSeek
8
+ Harness desktop and the Web GUI**.
9
+
10
+ **Current release: 1.2.1** · [Install or update](#install) · [中文说明](#中文说明)
8
11
 
9
12
  - **Next message** — after the agent replies, the empty composer shows a
10
13
  streamed suggestion for your likely next message. **Tab** accepts it as an
@@ -14,16 +17,16 @@ AI ghost text for the message box in the DeepSeek Harness Web GUI.
14
17
  completion without sending anything.
15
18
 
16
19
  Up to three candidates are requested per call. **Alt+Down / Alt+Up** cycle them,
17
- **Escape** dismisses one, and typing aborts any in-flight request and hides the
18
- ghost.
20
+ **Escape** dismisses the suggestions, and typing aborts any in-flight request
21
+ and hides the ghost. Streaming text is a preview: `Tab` becomes available when
22
+ the final candidates are ready.
19
23
 
20
24
  ![Typing a draft, the continuation ghost appearing, and Tab accepting it](https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/docs/demo.gif)
21
25
 
22
26
  ## How this differs from the other prediction plugins
23
27
 
24
- There are several composer-prediction plugins for DSH. Only this one does both
25
- halves — it suggests your next message **and** finishes the sentence you are
26
- already typing — and it keeps every decision on the keyboard: `Tab` accepts into
28
+ This plugin combines next-message suggestions and draft continuation with
29
+ multiple ghost-text candidates and keyboard cycling: `Tab` accepts into
27
30
  an editable draft, `Alt+Down`/`Alt+Up` cycle candidates, and nothing is ever sent
28
31
  for you.
29
32
 
@@ -33,23 +36,24 @@ for you.
33
36
  | Completes the draft while you type, at the caret | ✅ | — | ✅ | — |
34
37
  | Several candidates per request | ✅ 3, inline | — | ✅ 1–3 chips | — |
35
38
  | Cycle candidates without leaving the keyboard | ✅ `Alt+↓` / `Alt+↑` | — | — (click a chip) | — |
36
- | Accepts as an editable draft, never sends | ✅ | ✅ | chips send on click | — |
39
+ | Accepts as an editable draft, never sends | ✅ | ✅ | Tab edits the draft; recommendation chips send on click | ✅ |
37
40
  | Streamed rendering | ✅ SSE (`/v2`) | not documented | ✅ | ✅ |
38
41
  | Recent tool calls and outcomes in the prompt | ✅ | — | — | — |
39
42
  | Cache | ✅ per turn + per draft | ✅ | ✅ | not documented |
40
43
 
41
44
  "—" means the feature is not mentioned in that package's README as of
42
- 2026-10-23, not that it is impossible. If all you want is an after-turn
45
+ 2026-10-24, not that it is impossible. If all you want is an after-turn
43
46
  suggestion, `dsh-composer-prediction` is a perfectly good, more established
44
47
  choice — this plugin is for people who also want the caret continuation and
45
48
  keyboard-only candidate switching.
46
49
 
47
- Roadmap in this lane: a **Settings → General** row to switch predictions on and
48
- off (today you disable the bundle instead).
50
+ **Settings → General → Composer predictions** switches both modes on or off.
51
+ The choice is saved in the current browser or desktop window's local storage
52
+ on this device. Other browser origins and devices keep their own preference.
49
53
 
50
54
  ## Install
51
55
 
52
- Inside the DeepSeek Harness Web GUI: **Plugins → Install**, then enter
56
+ In the **DeepSeek Harness desktop app or Web GUI**: **Plugins → Install**, then enter
53
57
 
54
58
  ```
55
59
  dsh-composer-predictions
@@ -64,13 +68,20 @@ dsh plugin --profile <name> add dsh-composer-predictions
64
68
  Then restart the Harness (a profile addition is applied at boot). A linked
65
69
  source checkout is picked up again when the profile recomposes.
66
70
 
71
+ Or add the plugin market index to Discover (**Plugins → Discover → Index
72
+ sources**):
73
+
74
+ ```
75
+ https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/market-index.json
76
+ ```
77
+
67
78
  ## Use
68
79
 
69
80
  1. Send a message and let the agent finish — a dimmed next-message suggestion
70
81
  streams in with a `Tab` keycap.
71
82
  2. Or type anything and pause with the caret at the end of the draft — a
72
83
  continuation ghost appears right after the caret.
73
- 3. **Tab** turns the current candidate into an editable draft.
84
+ 3. When the `Tab` hint appears, **Tab** turns the current candidate into an editable draft.
74
85
  **Alt+Down** shows the next candidate, **Alt+Up** the previous one.
75
86
  **Escape** dismisses, and typing hides the ghost without sending anything.
76
87
  4. Accepting never submits the message; edit it or press Enter yourself.
@@ -79,6 +90,20 @@ source checkout is picked up again when the profile recomposes.
79
90
 
80
91
  ![Inline continuation ghost](https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/docs/continuation-ghost.png)
81
92
 
93
+ ## 中文说明
94
+
95
+ **DeepSeek Harness 桌面端 / Web 的 Tab 自动补全与输入预测。** 回复结束后,
96
+ 空输入框显示下一条消息的灰字建议;输入停顿时,续写光标末尾的草稿。
97
+ `Tab` 采纳已完成的候选为可编辑草稿,不会自动发送;`Alt+↓/↑` 切换最多三个候选,
98
+ `Esc` 关闭。设置 → 通用 → 输入预测可开关,设置保存在当前设备的浏览器中。
99
+
100
+ 安装或更新:**插件 → 安装 → `dsh-composer-predictions@1.2.1`**,然后重启 Harness。
101
+ CLI 用户可运行 `dsh plugin --profile web add dsh-composer-predictions@1.2.1`,
102
+ 把 `web` 换成自己的 profile。预测会调用会话模型并消耗 token,虽然不会出现在会话日志和 token 统计中。
103
+
104
+ 也可以把上方 [Install](#install) 中的市场索引添加到「插件 → 发现」。
105
+ 演示、完整配置与限制见本页;[1.2.1 发布说明](https://github.com/Stijnus/dsh-composer-predictions/releases/tag/v1.2.1)。
106
+
82
107
  ## How it works
83
108
 
84
109
  - The **Host half** registers two routes on the Harness web server:
@@ -139,7 +164,12 @@ A suggestion longer than 240 characters is cut at a word boundary.
139
164
  - Predictions are suggestions only; they never run tools or change the Session.
140
165
  - Accepting a suggestion and then editing it is a normal draft: it is only sent
141
166
  when you submit it.
142
- - Prediction calls do not appear in the Session log or its token meter.
167
+ - Prediction calls consume model tokens and may incur provider charges even
168
+ though they do not appear in the Session log or its token meter.
169
+ - Only final candidates can be accepted. A disconnected stream is discarded.
170
+ - A continuation is hidden when there is too little room after the caret; it
171
+ never shifts left over your draft.
172
+ - Final suggestions are announced through an accessible status message.
143
173
  - Requires a Harness build that provides `conversation.input.overlay`,
144
174
  `ctx.llm`, and `sessions.deriveMessages()`.
145
175
 
@@ -155,16 +185,18 @@ Probe a running GUI to watch the stream and cache timing:
155
185
  node tools/live-probe.mjs <sessionId> continue "Can you also"
156
186
  ```
157
187
 
158
- The 22 cases drive both Host endpoints against a fake Session and LLM service:
188
+ The 30 cases drive both Host endpoints against a fake Session and LLM service:
159
189
  prompt contents (including tool activity), candidate normalization and spacing,
160
190
  streaming events, per-turn and per-draft caching, route and effort selection,
161
191
  failure reporting, and request validation. Client helpers (continuation
162
192
  joining, SSE parsing, streaming candidates) are unit-tested through the bundle's
163
- internal `__testing` export.
193
+ internal `__testing` export. Component regression tests cover streaming acceptance,
194
+ refused insertions, right-edge placement, truncated streams and accessible copy.
164
195
 
165
196
  ## Uninstall
166
197
 
167
- Disable **Composer predictions** in **Plugins → Installed**, or remove it with
198
+ Switch **Settings → General → Composer predictions** off to pause suggestions.
199
+ To uninstall, remove the bundle with
168
200
  `dsh plugin --profile <name> remove dsh-composer-predictions`.
169
201
 
170
202
  ## Changelog
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.3",
4
- "description": "Tab-completion for the DeepSeek Harness composer: next-message ghost text plus inline continuation of your draft, with keyboard candidate cycling",
3
+ "version": "1.2.1",
4
+ "description": "Tab autocomplete for the DeepSeek Harness desktop and web composer: next-message ghost text, inline draft completion and keyboard candidate cycling",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "scripts": {
@@ -14,7 +14,12 @@
14
14
  "deepseek-harness",
15
15
  "composer",
16
16
  "autocomplete",
17
- "tab-completion"
17
+ "tab-completion",
18
+ "ghost-text",
19
+ "inline-completion",
20
+ "prediction",
21
+ "desktop",
22
+ "input-completion"
18
23
  ],
19
24
  "publishConfig": {
20
25
  "access": "public"