dsh-smooth-stream 0.4.3 → 0.6.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.
@@ -13,29 +13,88 @@
13
13
 
14
14
  type ClientRuntimeClient = typeof import('@deepseek-ai/dsh-client-runtime/client')
15
15
 
16
+ export interface SnapshotStoreLike<T> {
17
+ getSnapshot(): T
18
+ subscribe(listener: (state: T) => void): () => void
19
+ update(mutator: (draft: T) => void): void
20
+ set(next: T): void
21
+ }
22
+
23
+ function fallbackSnapshotStore<T extends object>(init: T): SnapshotStoreLike<T> {
24
+ let state = init
25
+ const listeners = new Set<(state: T) => void>()
26
+ return {
27
+ getSnapshot: () => state,
28
+ subscribe: listener => {
29
+ listeners.add(listener)
30
+ return () => { listeners.delete(listener) }
31
+ },
32
+ update: mutator => {
33
+ const draft = Object.create(
34
+ Object.getPrototypeOf(state),
35
+ Object.getOwnPropertyDescriptors(state),
36
+ ) as T
37
+ mutator(draft)
38
+ state = draft
39
+ for (const listener of [...listeners]) listener(state)
40
+ },
41
+ set: next => {
42
+ state = next
43
+ for (const listener of [...listeners]) listener(state)
44
+ },
45
+ }
46
+ }
47
+
16
48
  /**
17
49
  * Runtime require: the module-loader factory scopes its own resolver over the
18
50
  * booting kernel's table; under Node (vitest) there is no global require, so
19
51
  * fall back to `createRequire`.
20
52
  */
21
- function pickRequire(): NodeRequire {
22
- if (typeof require === 'function') return require
53
+ function pickRequire(): NodeRequire | null {
54
+ if (typeof require === 'function') {
55
+ try {
56
+ return require
57
+ } catch {
58
+ return null
59
+ }
60
+ }
23
61
  if (typeof process !== 'undefined' && typeof process.getBuiltinModule === 'function') {
24
- const { createRequire } = process.getBuiltinModule('node:module')
25
- return createRequire(process.cwd() + '/package.json')
62
+ try {
63
+ const { createRequire } = process.getBuiltinModule('node:module')
64
+ return createRequire(process.cwd() + '/package.json')
65
+ } catch {
66
+ return null
67
+ }
26
68
  }
27
- throw new TypeError('dsh-smooth-stream: no client module resolver available')
69
+ return null
28
70
  }
29
71
 
30
- let clientStore: ClientRuntimeClient
72
+ let clientStore: Partial<ClientRuntimeClient> | undefined
31
73
 
32
74
  try {
33
- // 0.1.2+ seeds the split store package directly into the module table.
34
- clientStore = pickRequire()('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
75
+ const req = pickRequire()
76
+ if (req !== null) {
77
+ try {
78
+ // 0.1.2+ seeds the split store package directly into the module table.
79
+ clientStore = req('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
80
+ } catch {
81
+ try {
82
+ // ≤ 0.1.1 owns the store engine on the client-runtime client face.
83
+ clientStore = req('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
84
+ } catch {
85
+ // Fallback below
86
+ }
87
+ }
88
+ }
35
89
  } catch {
36
- // ≤ 0.1.1 owns the store engine on the client-runtime client face.
37
- clientStore = pickRequire()('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
90
+ // Fallback below
91
+ }
92
+
93
+ export interface SnapshotStore<T> {
94
+ getSnapshot(): T
95
+ subscribe(listener: () => void): () => void
96
+ update(mutator: (draft: T) => void): void
97
+ set(next: T): void
38
98
  }
39
99
 
40
- export const createSnapshotStore = clientStore.createSnapshotStore
41
- export type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
100
+ export const createSnapshotStore = (clientStore?.createSnapshotStore ?? fallbackSnapshotStore) as <T extends object>(init: T) => SnapshotStore<T>
@@ -7,7 +7,6 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
8
8
  import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
9
9
  import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
10
- import { AutoCollapseController } from './auto-collapse-controller.ts'
11
10
  import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
12
11
  import { SmoothStreamCard } from './SmoothStreamCard.tsx'
13
12
  import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
@@ -88,7 +87,10 @@ function readBootConfig(): StreamConfig {
88
87
  * @param ctx - Browser context carrying the slot registry.
89
88
  * @returns Restorer that puts the original components back.
90
89
  */
91
- function wrapAgentChatRows(ctx: ClientContext): () => void {
90
+ function wrapAgentChatRows(
91
+ ctx: ClientContext,
92
+ useControlScroll: () => boolean,
93
+ ): () => void {
92
94
  const restores: Array<() => void> = []
93
95
  const wrapped = new WeakSet<object>()
94
96
 
@@ -99,7 +101,7 @@ function wrapAgentChatRows(ctx: ClientContext): () => void {
99
101
  const current = entry.component
100
102
  if (!isWrappableComponent(current) || wrapped.has(current)) continue
101
103
  const inner = current as ComponentType<FollowWrapProps>
102
- const next = wrapFollowNodeView(inner)
104
+ const next = wrapFollowNodeView(inner, useControlScroll)
103
105
  wrapped.add(next)
104
106
  entry.component = next
105
107
  restores.push(() => {
@@ -154,8 +156,9 @@ class SettingsCell {
154
156
  if (
155
157
  pending === this.pending
156
158
  && next.enabled === this.value.enabled
159
+ && next.controlScroll === this.value.controlScroll
160
+ && next.motionPreference === this.value.motionPreference
157
161
  && next.thinkAutoExpand === this.value.thinkAutoExpand
158
- && next.autoCollapse === this.value.autoCollapse
159
162
  && next.debugEnabled === this.value.debugEnabled
160
163
  && next.debugTuning === this.value.debugTuning
161
164
  ) return
@@ -169,11 +172,6 @@ class SettingsCell {
169
172
  return !this.pending && this.value.enabled
170
173
  }
171
174
 
172
- /** Whether finished turns should fold behind a summary row right now. */
173
- autoCollapseActive(): boolean {
174
- return !this.pending && this.value.autoCollapse
175
- }
176
-
177
175
  readonly getSnapshot = (): StreamSettings => this.value
178
176
 
179
177
  readonly subscribe = (listener: () => void): () => void => {
@@ -196,25 +194,11 @@ class SettingsCell {
196
194
  export function apply(ctx: ClientContext): void {
197
195
  const config = readBootConfig()
198
196
  const settings = new SettingsCell()
199
-
200
- // Auto-collapse works at the DOM level and is independent of the renderer
201
- // takeover: it also folds finished turns when the built-in Harness renderer
202
- // owns the conversation. The lifecycle rides the guaranteed `slots` service
203
- // (this entry's own inject list) and follows the same live settings cell, so
204
- // flipping the preference restores every folded row immediately.
205
- ctx.inject(['slots'], () => {
206
- const autoCollapse = new AutoCollapseController()
207
- const syncAutoCollapse = (): void => {
208
- if (settings.autoCollapseActive()) autoCollapse.start()
209
- else autoCollapse.stop()
210
- }
211
- const unsubscribe = settings.subscribe(syncAutoCollapse)
212
- syncAutoCollapse()
213
- return () => {
214
- unsubscribe()
215
- autoCollapse.stop()
216
- }
217
- })
197
+ const useControlScroll = (): boolean => useSyncExternalStore(
198
+ settings.subscribe,
199
+ () => settings.getSnapshot().controlScroll,
200
+ () => settings.getSnapshot().controlScroll,
201
+ )
218
202
 
219
203
  // The card talks to the plugin-owned loopback RPC, so the core settings
220
204
  // namespace allowlist cannot make it disappear. The stream still applies
@@ -286,6 +270,8 @@ export function apply(ctx: ClientContext): void {
286
270
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
287
271
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
288
272
  thinkAutoExpand: preferences.thinkAutoExpand,
273
+ controlScroll: preferences.controlScroll,
274
+ motionPreference: preferences.motionPreference,
289
275
  })
290
276
  }
291
277
  ctx.slots.inject('conversation.chat.node', () => {
@@ -298,7 +284,7 @@ export function apply(ctx: ClientContext): void {
298
284
  return
299
285
  }
300
286
  if (releaseTakeover !== undefined) return
301
- const unwrap = wrapAgentChatRows(ctx)
287
+ const unwrap = wrapAgentChatRows(ctx, useControlScroll)
302
288
  const unshadow = ctx.slots.register({
303
289
  name: 'conversation.chat.node',
304
290
  key: 'assistant-step',
@@ -7,8 +7,12 @@ export const NS = 'settings.smoothStream'
7
7
  export type SmoothStreamLocaleKey =
8
8
  | 'title' | 'description'
9
9
  | 'enabled' | 'enabledHint'
10
+ | 'controlScroll' | 'controlScrollHint'
11
+ | 'motionPreference' | 'motionPreferenceHint'
12
+ | 'motionAuto' | 'motionAutoHint'
13
+ | 'motionForceSmooth' | 'motionForceSmoothHint'
14
+ | 'motionForceReduced' | 'motionForceReducedHint'
10
15
  | 'thinkAutoExpand' | 'thinkAutoExpandHint'
11
- | 'autoCollapse' | 'autoCollapseHint'
12
16
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
13
17
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
14
18
  | 'debugLive' | 'debugIdle' | 'debugUnsaved'
@@ -35,10 +39,18 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
35
39
  description: 'How replies are revealed while they stream.',
36
40
  enabled: 'Enable smooth streaming',
37
41
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
42
+ controlScroll: 'Take over scrolling',
43
+ controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
44
+ motionPreference: 'Motion',
45
+ motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
46
+ motionAuto: 'Follow system',
47
+ motionAutoHint: 'Reduced-motion systems get raw text (accessibility first).',
48
+ motionForceSmooth: 'Always smooth',
49
+ motionForceSmoothHint: 'Keep the smoothing engine even when the system asks for reduced motion.',
50
+ motionForceReduced: 'Always raw',
51
+ motionForceReducedHint: 'Render raw text even when the system allows motion.',
38
52
  thinkAutoExpand: 'Auto-expand thinking',
39
53
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
40
- autoCollapse: 'Collapse finished work',
41
- autoCollapseHint: 'When a reply finishes, fold its thinking, tools, and intermediate output behind one “Processed” summary so only the final answer shows. Click the summary to expand again; works with streaming on or off.',
42
54
  debugEnabled: 'Show render diagnostics',
43
55
  debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
44
56
  debugUnavailable: 'Live diagnostics require a newer plugin Host.',
@@ -115,10 +127,18 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
115
127
  description: '回复在流式输出时如何逐字展现。',
116
128
  enabled: '启用丝滑流式渲染',
117
129
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
130
+ controlScroll: '接管滚动',
131
+ controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
132
+ motionPreference: '动效偏好',
133
+ motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
134
+ motionAuto: '跟随系统',
135
+ motionAutoHint: '系统开启减少动态效果时直接呈现原始文本(优先无障碍)。',
136
+ motionForceSmooth: '始终平滑',
137
+ motionForceSmoothHint: '即使系统要求减少动态效果,仍保持平滑流式渲染。',
138
+ motionForceReduced: '始终原始',
139
+ motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
118
140
  thinkAutoExpand: '自动展开思考',
119
141
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
120
- autoCollapse: '完成后自动折叠',
121
- autoCollapseHint: '回复处理完成后,把思考、工具与过程输出折叠为一行“已处理”摘要,只展示最终回复;点击摘要可再次展开。开启或关闭流式渲染均可用,默认开启。',
122
142
  debugEnabled: '显示渲染调试面板',
123
143
  debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
124
144
  debugUnavailable: '当前 Host 版本不支持实时调试,请先更新插件 Host。',
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_STREAM_DEBUG_TUNING,
6
6
  DEFAULT_STREAM_SETTINGS,
7
7
  type StreamDebugTuning,
8
+ type StreamMotionPreference,
8
9
  type StreamSettings,
9
10
  } from '../settings.ts'
10
11
  import type {
@@ -22,8 +23,9 @@ export interface SmoothStreamCardState {
22
23
  saving: boolean
23
24
  failed: boolean
24
25
  enabled: boolean
26
+ controlScroll: boolean
27
+ motionPreference: StreamMotionPreference
25
28
  thinkAutoExpand: boolean
26
- autoCollapse: boolean
27
29
  debugEnabled: boolean
28
30
  debugTuning: StreamDebugTuning
29
31
  debugAvailable: boolean
@@ -52,7 +54,7 @@ export class SmoothStreamCardController {
52
54
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
53
55
  private loaded: StreamSettingsView | undefined
54
56
  private loadedDebug: StreamDebugSettingsView | undefined
55
- private stagedBase: Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> | undefined
57
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
56
58
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
57
59
  private saving = false
58
60
  private failed = false
@@ -90,12 +92,13 @@ export class SmoothStreamCardController {
90
92
  hooks: { smoothStreamCard: this.store },
91
93
  edit: (patch) => {
92
94
  if (this.saving) return
93
- if (patch.enabled !== undefined || patch.thinkAutoExpand !== undefined || patch.autoCollapse !== undefined) {
95
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
94
96
  this.stagedBase = {
95
97
  ...this.baseValues(),
96
98
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
99
+ ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
100
+ ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
97
101
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
98
- ...(patch.autoCollapse === undefined ? {} : { autoCollapse: patch.autoCollapse }),
99
102
  }
100
103
  }
101
104
  if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
@@ -140,11 +143,12 @@ export class SmoothStreamCardController {
140
143
  }
141
144
  }
142
145
 
143
- private baseValues(): Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> {
146
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
144
147
  return this.stagedBase ?? {
145
148
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
149
+ controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
150
+ motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
146
151
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
147
- autoCollapse: this.loaded?.autoCollapse ?? DEFAULT_STREAM_SETTINGS.autoCollapse,
148
152
  }
149
153
  }
150
154
 
@@ -23,8 +23,9 @@ function settingsView(value: unknown): StreamSettingsView {
23
23
  || !['npm', 'development', 'unmanaged'].includes(data.installation as string)
24
24
  || typeof data.writable !== 'boolean'
25
25
  || typeof data.enabled !== 'boolean'
26
+ || typeof data.controlScroll !== 'boolean'
27
+ || !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
26
28
  || typeof data.thinkAutoExpand !== 'boolean'
27
- || typeof data.autoCollapse !== 'boolean'
28
29
  || typeof data.canUpgrade !== 'boolean') {
29
30
  throw new Error('dsh-smooth-stream: malformed settings response')
30
31
  }
@@ -68,7 +69,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
68
69
  /** Narrow client contract consumed by the staged settings-card controller. */
69
70
  export interface SmoothStreamSettingsApi {
70
71
  read(): Promise<StreamSettingsView>
71
- write(settings: Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
72
+ write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
72
73
  readDebug(): Promise<StreamDebugSettingsView>
73
74
  writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
74
75
  upgrade(): Promise<StreamUpgradeView>
@@ -80,14 +81,15 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
80
81
  async read(): Promise<StreamSettingsView> {
81
82
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
82
83
  },
83
- async write(settings: Pick<StreamSettings, 'enabled' | 'thinkAutoExpand' | 'autoCollapse'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
84
+ async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
84
85
  return settingsView(accepted(await connection.rpc.call(
85
86
  STREAM_SETTINGS_RPC_CHANNEL,
86
87
  STREAM_SETTINGS_RPC.write,
87
88
  {
88
89
  enabled: settings.enabled,
90
+ controlScroll: settings.controlScroll,
91
+ motionPreference: settings.motionPreference,
89
92
  thinkAutoExpand: settings.thinkAutoExpand,
90
- autoCollapse: settings.autoCollapse,
91
93
  ...(settings.debugEnabled === undefined || settings.debugTuning === undefined
92
94
  ? {}
93
95
  : { debugEnabled: settings.debugEnabled, debugTuning: settings.debugTuning }),