dsh-smooth-stream 0.6.1 → 0.7.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.
@@ -0,0 +1,27 @@
1
+ import type { ComponentType } from 'react'
2
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
3
+
4
+ type IconProps = { className?: string | undefined; size?: number | undefined }
5
+ type IconExports = Record<string, ComponentType<IconProps> | undefined>
6
+
7
+ // Harness renamed size-suffixed icon exports to weight-suffixed names. Resolve
8
+ // at runtime so this plugin bundle works with both the older published
9
+ // primitive package used for development and current Harness module tables.
10
+ const icons = primitives as unknown as IconExports
11
+
12
+ function resolveIcon(currentName: string, legacyName: string): ComponentType<IconProps> {
13
+ const icon = icons[currentName] ?? icons[legacyName]
14
+ if (icon === undefined) {
15
+ throw new Error(`dsh-smooth-stream: Harness icon export is missing (${currentName}, ${legacyName})`)
16
+ }
17
+ return icon
18
+ }
19
+
20
+ export const IconChevronDown = resolveIcon('IconChevronDownOutlineRegular', 'IconChevronDownOutline14')
21
+ export const IconClose = resolveIcon('IconCloseOutlineRegular', 'IconCloseOutline16')
22
+ export const IconCode = resolveIcon('IconCodeOutlineRegular', 'IconCodeOutline16')
23
+ export const IconCopy = resolveIcon('IconCopyOutlineRegular', 'IconCopyOutline16')
24
+ export const IconQuestion = resolveIcon('IconQuestionOutlineRegular', 'IconQuestionOutline14')
25
+ export const IconRefresh = resolveIcon('IconRefreshOutlineRegular', 'IconRefreshOutline14')
26
+ export const IconRefreshSmall = resolveIcon('IconRefreshOutlineRegular', 'IconRefreshOutline16')
27
+ export const IconThink = resolveIcon('IconThinkOutlineRegular', 'IconThinkOutline14')
@@ -13,7 +13,7 @@ import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
13
13
  import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
14
14
  import { DebugPanel } from './DebugPanel.tsx'
15
15
  import { debugRuntime } from './debugRuntime.ts'
16
- import { NS as SETTINGS_NS, en, zh } from './locales.ts'
16
+ import { NS as SETTINGS_NS, CHAT_NS, en, zh, chatEn, chatZh } from './locales.ts'
17
17
  import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
18
18
  import { DEFAULT_STREAM_SETTINGS, STREAM_SETTINGS_NS, type StreamSettings } from '../settings.ts'
19
19
 
@@ -37,8 +37,34 @@ const STREAM_PRESETS: readonly string[] = ['realtime', 'balanced', 'silky']
37
37
  * generic follow boundary. This is deliberately keyed by the owner that
38
38
  * provides the renderer, not by individual tool names, so new Context,
39
39
  * Command, and Tool rows are covered automatically.
40
+ *
41
+ * Host chrome and controllers are excluded on a different rule: `turn-process`
42
+ * (the completed-turn process summary control) and `turn-tail` (the turn usage
43
+ * footer) are not Agent output. The Host renders them as bare controls and
44
+ * flips their visibility itself, so wrapping them has three measured costs and
45
+ * no benefit:
46
+ *
47
+ * 1. The wrapper element defeats ChatView's `.flowItem:empty { display: none }`
48
+ * rule. A `turn-process` renderer returns null whenever the Host's fold is
49
+ * unavailable (e.g. `processWindowReady === false`), and that null render is
50
+ * exactly what the `:empty` rule exists to erase; with a wrapper the row
51
+ * stays in flow and still consumes the 16px column gap, painting an empty
52
+ * band where the fold control should be.
53
+ * 2. The summary label (`N tool calls · M messages`) is not streamed model
54
+ * text, so pushing it through the character-reveal engine is wrong by
55
+ * construction.
56
+ * 3. The row would inherit the generic entrance animation (opacity + clip-path)
57
+ * on a row the Host may mount and hide inside a single commit.
40
58
  */
41
- const SKIP_WRAP = new Set(['assistant-step', 'user', 'steering', 'command-input'])
59
+ const SKIP_WRAP = new Set([
60
+ 'assistant-step',
61
+ 'user',
62
+ 'steering',
63
+ 'command-input',
64
+ 'turn-process',
65
+ 'turn-tail',
66
+ 'tool-call',
67
+ ])
42
68
 
43
69
  /** React function/class or an exotic component such as memo/forwardRef/lazy. */
44
70
  function isWrappableComponent(value: unknown): value is ComponentType<FollowWrapProps> {
@@ -157,6 +183,7 @@ class SettingsCell {
157
183
  pending === this.pending
158
184
  && next.enabled === this.value.enabled
159
185
  && next.controlScroll === this.value.controlScroll
186
+ && next.preset === this.value.preset
160
187
  && next.motionPreference === this.value.motionPreference
161
188
  && next.thinkAutoExpand === this.value.thinkAutoExpand
162
189
  && next.logarithmicFade === this.value.logarithmicFade
@@ -173,6 +200,16 @@ class SettingsCell {
173
200
  return !this.pending && this.value.enabled
174
201
  }
175
202
 
203
+ /**
204
+ * True only once an attached settings service has produced its loaded values.
205
+ * Until then — and when no settings service is composed at all — `value` is
206
+ * still the shared client default, so callers that must not let that default
207
+ * shadow the install-time config have to ask before trusting it.
208
+ */
209
+ ready(): boolean {
210
+ return this.card?.getSnapshot().status === 'ready'
211
+ }
212
+
176
213
  readonly getSnapshot = (): StreamSettings => this.value
177
214
 
178
215
  readonly subscribe = (listener: () => void): () => void => {
@@ -201,6 +238,55 @@ export function apply(ctx: ClientContext): void {
201
238
  () => settings.getSnapshot().controlScroll,
202
239
  )
203
240
 
241
+ /**
242
+ * Layered `t` for the assistant renderer.
243
+ *
244
+ * The renderer's keys have no single owner: `conversation` owns the
245
+ * `image.*` family in every Harness version, `chat` (0.1.5+) owns
246
+ * `message.think`, and three `message.*` keys MOVED from `conversation` to
247
+ * `chat` between the version this package pins and the current one. Binding
248
+ * the slot to either namespace alone degrades a real slice of the UI to raw
249
+ * keys, which is the defect this replaces.
250
+ *
251
+ * The order is deliberate: `conversation` first, so the pinned Harness keeps
252
+ * resolving the keys it still owns there; then `chat` for what moved or is
253
+ * new; then this plugin's own namespace for keys no Harness version
254
+ * provides. A namespace that is not registered returns its key unchanged
255
+ * (`LocaleRuntime.bind` does not throw), so an older Harness without `chat`
256
+ * falls straight through.
257
+ *
258
+ * The reference is built once and held stable: the seat feeds a memoized
259
+ * renderer, and a fresh identity per render would defeat that memoization.
260
+ * Until the locale service arrives the seat's own binding stays in use.
261
+ */
262
+ let assistantT: AssistantProps['t'] | undefined
263
+
264
+ // Locale routing belongs to the renderer lifecycle, not to the optional
265
+ // Connection-backed settings card. Keeping this injection independent means
266
+ // a deployment can stream localized replies without Connection, and a
267
+ // transient disconnect cannot dispose the renderer's fallback dictionary.
268
+ ctx.inject(['locale'], (localeCtx) => {
269
+ localeCtx.effect(
270
+ () => localeCtx.locale.register(CHAT_NS, { zh: chatZh, en: chatEn }),
271
+ 'dsh-smooth-stream: conversation fallback dictionary',
272
+ )
273
+ const conversationT = localeCtx.locale.bind('conversation')
274
+ const chatT = localeCtx.locale.bind('chat') as (key: string, params?: Record<string, unknown>) => string
275
+ const fallbackT = localeCtx.locale.bind(CHAT_NS) as (key: string, params?: Record<string, unknown>) => string
276
+ const merged = (key: string, params?: Record<string, unknown>): string => {
277
+ const primary = (conversationT as (k: string, p?: unknown) => string)(key, params)
278
+ if (primary !== key) return primary
279
+ const secondary = chatT(key, params)
280
+ if (secondary !== key) return secondary
281
+ return fallbackT(key, params)
282
+ }
283
+ const bound = merged as unknown as AssistantProps['t']
284
+ assistantT = bound
285
+ return () => {
286
+ if (assistantT === bound) assistantT = undefined
287
+ }
288
+ })
289
+
204
290
  // The card talks to the plugin-owned loopback RPC, so the core settings
205
291
  // namespace allowlist cannot make it disappear. The stream still applies
206
292
  // with defaults when the optional Settings UI or Connection is absent.
@@ -265,8 +351,18 @@ export function apply(ctx: ClientContext): void {
265
351
  )
266
352
  return createElement(TypewriterAssistantNodeView, {
267
353
  ...props,
354
+ // Override the seat's single-namespace binding with the layered lookup
355
+ // described above. Falls back to the seat's own binding only if the
356
+ // locale service never arrived (renderer then receives the prop it
357
+ // already had).
358
+ ...(assistantT === undefined ? {} : { t: assistantT }),
268
359
  mode: config.mode,
269
- preset: config.preset,
360
+ // Loaded settings hold the resolved preset (user layer over the install
361
+ // config), so they own the prop. Without them — no settings service, a
362
+ // failed load, or the frames before the first read lands — the shared
363
+ // client default is NOT an authority on the installed preset; the boot
364
+ // config is.
365
+ preset: settings.ready() ? preferences.preset ?? config.preset : config.preset,
270
366
  revealCharsPerSec: config.revealCharsPerSec,
271
367
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
272
368
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
@@ -278,6 +374,7 @@ export function apply(ctx: ClientContext): void {
278
374
  }
279
375
  ctx.slots.inject('conversation.chat.node', () => {
280
376
  let releaseTakeover: (() => void) | undefined
377
+ let usedSwap = false
281
378
 
282
379
  const syncTakeover = (): void => {
283
380
  if (!settings.takeoverEnabled()) {
@@ -287,24 +384,67 @@ export function apply(ctx: ClientContext): void {
287
384
  }
288
385
  if (releaseTakeover !== undefined) return
289
386
  const unwrap = wrapAgentChatRows(ctx, useControlScroll)
290
- const unshadow = ctx.slots.register({
291
- name: 'conversation.chat.node',
292
- key: 'assistant-step',
293
- priority: -100,
294
- locale: 'conversation',
295
- registrant: 'dsh-smooth-stream',
296
- }, configured)
387
+ // dsh 0.2.x renders every keyed entry of `conversation.chat.node` as
388
+ // its own seat, so a lower-priority registration no longer shadows the
389
+ // built-in assistant row — the same assistant step then renders twice
390
+ // (built-in + takeover), duplicating every reasoning block in the
391
+ // transcript. Swap the built-in entry's component in place instead,
392
+ // exactly like the other keyed rows below; this keeps the original
393
+ // children, locale, and inject seats on every kernel generation. The
394
+ // shadow registration survives only as a fallback for compositions
395
+ // that declare no built-in assistant row to swap.
396
+ const builtin = ctx.slots.entries('conversation.chat.node').find(
397
+ entry => entry.options.key === 'assistant-step'
398
+ // The pinned SlotOptions type predates `registrant`; every current
399
+ // runtime carries it (see the register spread in the slots package).
400
+ && (entry.options as { registrant?: string }).registrant !== 'dsh-smooth-stream'
401
+ && entry.component !== configured,
402
+ )
403
+ let unshadow: () => void
404
+ if (builtin !== undefined && isWrappableComponent(builtin.component)) {
405
+ usedSwap = true
406
+ const inner = builtin.component as ComponentType<FollowWrapProps>
407
+ builtin.component = configured
408
+ unshadow = () => {
409
+ if (builtin.component === configured) builtin.component = inner
410
+ }
411
+ } else {
412
+ unshadow = ctx.slots.register({
413
+ name: 'conversation.chat.node',
414
+ key: 'assistant-step',
415
+ priority: -100,
416
+ // `conversation` (not `chat`): it is the namespace the pinned Harness
417
+ // actually registers, so it is the one whose keys must keep resolving.
418
+ // The layered `t` handed to the renderer covers what this namespace
419
+ // does not own. A namespace the composition does not declare would
420
+ // degrade every `t()` call to its raw key.
421
+ locale: 'conversation',
422
+ registrant: 'dsh-smooth-stream',
423
+ }, configured)
424
+ }
297
425
  releaseTakeover = () => {
298
- // Stop observing slot changes before the shadow entry is removed.
426
+ // Stop observing slot changes before the takeover is removed.
299
427
  unwrap()
300
428
  unshadow()
301
429
  }
302
430
  }
303
431
 
432
+ // If the shadow fallback was taken because the built-in assistant row
433
+ // had not registered yet, re-take through the in-place swap as soon as
434
+ // it appears, so 0.2.x never renders the row twice.
435
+ const offSlotChange = ctx.on('slots/changed', (key: string) => {
436
+ if (key !== 'conversation.chat.node' || usedSwap || releaseTakeover === undefined) return
437
+ if (!settings.takeoverEnabled()) return
438
+ releaseTakeover()
439
+ releaseTakeover = undefined
440
+ syncTakeover()
441
+ })
442
+
304
443
  const unsubscribe = settings.subscribe(syncTakeover)
305
444
  syncTakeover()
306
445
  return () => {
307
446
  unsubscribe()
447
+ offSlotChange()
308
448
  releaseTakeover?.()
309
449
  }
310
450
  })
@@ -3,11 +3,59 @@
3
3
  /** Dictionary namespace owned by this plugin's settings card. */
4
4
  export const NS = 'settings.smoothStream'
5
5
 
6
+ /**
7
+ * Namespace owning the conversation keys THIS PLUGIN must be able to render
8
+ * but the Harness does not always provide.
9
+ *
10
+ * The plugin consumes keys from two Harness namespaces, and which one owns a
11
+ * given key is not stable across Harness versions:
12
+ *
13
+ * - `conversation` (`dsh-client-ui-conversation`) owns the `image.*` family in
14
+ * every version, and also owned `message.stopped`, `message.unknownBlock`
15
+ * and `json.truncated` before those moved to `chat`.
16
+ * - `chat` (`dsh-client-ui-chat`, present from 0.1.5 on) owns `message.think`
17
+ * and now owns the three keys above. It does not exist at all in rc.6, the
18
+ * version this package still pins in devDependencies.
19
+ *
20
+ * Neither namespace alone can serve every key this plugin renders, so
21
+ * `index.ts` layers them. This plugin-owned namespace is the final layer; it
22
+ * exists because a plugin may NOT register into `chat` or `conversation` —
23
+ * `LocaleRuntime.register` throws when the (namespace, locale) pair already
24
+ * has an owner, and the Harness owns both.
25
+ */
26
+ export const CHAT_NS = 'smoothStream.chat'
27
+
28
+ /**
29
+ * Keys the plugin resolves through {@link CHAT_NS}. Only keys with no
30
+ * dependable Harness owner belong here; anything the Harness already provides
31
+ * must keep coming from the Harness so a language pack can override it.
32
+ */
33
+ export type SmoothStreamChatKey = 'message.think' | 'image.serviceUnavailable'
34
+
35
+ /** English fallback for {@link SmoothStreamChatKey}. */
36
+ export const chatEn: Record<SmoothStreamChatKey, string> = {
37
+ 'message.think': 'Think',
38
+ // Removed from the Harness dictionaries in 0.1.5 (rc.6 had it under
39
+ // `conversation`), and the plugin interpolates it into a thrown Error, so
40
+ // the raw key must never reach a reader.
41
+ 'image.serviceUnavailable': 'Image service unavailable',
42
+ }
43
+
44
+ /** Simplified Chinese fallback for {@link SmoothStreamChatKey}. */
45
+ export const chatZh: Record<SmoothStreamChatKey, string> = {
46
+ 'message.think': '思考',
47
+ 'image.serviceUnavailable': '图片服务不可用',
48
+ }
49
+
6
50
  /** Locale keys the card renders. */
7
51
  export type SmoothStreamLocaleKey =
8
52
  | 'title' | 'description'
9
53
  | 'enabled' | 'enabledHint'
10
54
  | 'controlScroll' | 'controlScrollHint'
55
+ | 'preset' | 'presetHint'
56
+ | 'presetRealtime' | 'presetRealtimeHint'
57
+ | 'presetBalanced' | 'presetBalancedHint'
58
+ | 'presetSilky' | 'presetSilkyHint'
11
59
  | 'motionPreference' | 'motionPreferenceHint'
12
60
  | 'motionAuto' | 'motionAutoHint'
13
61
  | 'motionForceSmooth' | 'motionForceSmoothHint'
@@ -22,6 +70,8 @@ export type SmoothStreamLocaleKey =
22
70
  | 'debugFps' | 'debugFrameTime' | 'debugBacklog' | 'debugRevealSpeed' | 'debugProgress'
23
71
  | 'debugFollowState' | 'debugFollowing' | 'debugReleased'
24
72
  | 'debugLag' | 'debugVelocity' | 'debugReserve' | 'debugCapacity' | 'debugAppliedScale'
73
+ | 'debugTerminalPhase' | 'debugOwnedRunway' | 'debugTerminalBudget' | 'debugBaselineShift'
74
+ | 'debugAnchorDelta' | 'debugPhaseLive' | 'debugPhaseDrain' | 'debugPhaseCascade' | 'debugPhaseNatural'
25
75
  | 'debugRevealMultiplier' | 'debugQueuePressure' | 'debugMaxReveal'
26
76
  | 'debugSpringStiffness' | 'debugSpringDamping' | 'debugSpringMass'
27
77
  | 'debugRunway' | 'debugReserveResponse' | 'debugBackpressureMin'
@@ -42,6 +92,14 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
42
92
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
43
93
  controlScroll: 'Take over scrolling',
44
94
  controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
95
+ preset: 'Pacing preset',
96
+ presetHint: 'Smoothing cadence profile for text reveal and glide.',
97
+ presetRealtime: 'Realtime',
98
+ presetRealtimeHint: 'Aggressive cadence, low latency, tracks output closely.',
99
+ presetBalanced: 'Balanced',
100
+ presetBalancedHint: 'Default balance between pace and visual rhythm.',
101
+ presetSilky: 'Silky',
102
+ presetSilkyHint: 'Higher backlog buffer, gentle EMA damping, maximum fluidity.',
45
103
  motionPreference: 'Motion',
46
104
  motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
47
105
  motionAuto: 'Follow system',
@@ -85,6 +143,15 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
85
143
  debugReserve: 'Reserve',
86
144
  debugCapacity: 'Capacity',
87
145
  debugAppliedScale: 'Applied scale',
146
+ debugTerminalPhase: 'Terminal phase',
147
+ debugOwnedRunway: 'Owned runway',
148
+ debugTerminalBudget: 'Terminal budget',
149
+ debugBaselineShift: 'Baseline shift',
150
+ debugAnchorDelta: 'Anchor Δ',
151
+ debugPhaseLive: 'live',
152
+ debugPhaseDrain: 'terminal-drain',
153
+ debugPhaseCascade: 'host-cascade',
154
+ debugPhaseNatural: 'natural',
88
155
  debugRevealMultiplier: 'Reveal multiplier',
89
156
  debugQueuePressure: 'Queue pressure',
90
157
  debugMaxReveal: 'Maximum reveal',
@@ -132,6 +199,14 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
132
199
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
133
200
  controlScroll: '接管滚动',
134
201
  controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
202
+ preset: '手感预设',
203
+ presetHint: '文字吐字节奏与视口跟随阻尼风格。',
204
+ presetRealtime: '激进快速 (realtime)',
205
+ presetRealtimeHint: '缓冲低、流速快,紧跟模型生成。',
206
+ presetBalanced: '均衡自然 (balanced)',
207
+ presetBalancedHint: '官方默认,平衡流速与节奏感。',
208
+ presetSilky: '极致丝滑 (silky)',
209
+ presetSilkyHint: '字符积压缓冲加大、阻尼更高、EMA 平滑度更好,视觉呼吸感最强。',
135
210
  motionPreference: '动效偏好',
136
211
  motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
137
212
  motionAuto: '跟随系统',
@@ -175,6 +250,15 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
175
250
  debugReserve: '预留空间',
176
251
  debugCapacity: '安全容量',
177
252
  debugAppliedScale: '实际倍率',
253
+ debugTerminalPhase: '收尾阶段',
254
+ debugOwnedRunway: '实体留白',
255
+ debugTerminalBudget: '收尾预算',
256
+ debugBaselineShift: '基线位移',
257
+ debugAnchorDelta: '锚点位移',
258
+ debugPhaseLive: '流式中',
259
+ debugPhaseDrain: '收尾排空',
260
+ debugPhaseCascade: '宿主交接',
261
+ debugPhaseNatural: '自然态',
178
262
  debugRevealMultiplier: '揭示倍率',
179
263
  debugQueuePressure: '队列压力',
180
264
  debugMaxReveal: '最大揭示速度',
@@ -217,5 +301,6 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
217
301
  declare module '@deepseek-ai/dsh-client-ui-slots' {
218
302
  interface LocaleNamespaceMap {
219
303
  'settings.smoothStream': SmoothStreamLocaleKey
304
+ 'smoothStream.chat': SmoothStreamChatKey
220
305
  }
221
306
  }
@@ -7,6 +7,7 @@ import {
7
7
  type StreamDebugTuning,
8
8
  type StreamMotionPreference,
9
9
  type StreamSettings,
10
+ type StreamSmoothingPreset,
10
11
  } from '../settings.ts'
11
12
  import type {
12
13
  StreamDebugSettingsView,
@@ -24,6 +25,7 @@ export interface SmoothStreamCardState {
24
25
  failed: boolean
25
26
  enabled: boolean
26
27
  controlScroll: boolean
28
+ preset: StreamSmoothingPreset
27
29
  motionPreference: StreamMotionPreference
28
30
  thinkAutoExpand: boolean
29
31
  logarithmicFade: boolean
@@ -55,7 +57,7 @@ export class SmoothStreamCardController {
55
57
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
56
58
  private loaded: StreamSettingsView | undefined
57
59
  private loadedDebug: StreamDebugSettingsView | undefined
58
- private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
60
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
59
61
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
60
62
  private saving = false
61
63
  private failed = false
@@ -93,11 +95,12 @@ export class SmoothStreamCardController {
93
95
  hooks: { smoothStreamCard: this.store },
94
96
  edit: (patch) => {
95
97
  if (this.saving) return
96
- if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
98
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.preset !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
97
99
  this.stagedBase = {
98
100
  ...this.baseValues(),
99
101
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
100
102
  ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
103
+ ...(patch.preset === undefined ? {} : { preset: patch.preset }),
101
104
  ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
102
105
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
103
106
  ...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
@@ -145,10 +148,11 @@ export class SmoothStreamCardController {
145
148
  }
146
149
  }
147
150
 
148
- private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
151
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
149
152
  return this.stagedBase ?? {
150
153
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
151
154
  controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
155
+ preset: this.loaded?.preset ?? DEFAULT_STREAM_SETTINGS.preset,
152
156
  motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
153
157
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
154
158
  logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
@@ -24,13 +24,14 @@ function settingsView(value: unknown): StreamSettingsView {
24
24
  || typeof data.writable !== 'boolean'
25
25
  || typeof data.enabled !== 'boolean'
26
26
  || typeof data.controlScroll !== 'boolean'
27
+ || (data.preset !== undefined && !['realtime', 'balanced', 'silky'].includes(data.preset as string))
27
28
  || !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
28
29
  || typeof data.thinkAutoExpand !== 'boolean'
29
30
  || (data.logarithmicFade !== undefined && typeof data.logarithmicFade !== 'boolean')
30
31
  || typeof data.canUpgrade !== 'boolean') {
31
32
  throw new Error('dsh-smooth-stream: malformed settings response')
32
33
  }
33
- return { ...data, logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
34
+ return { ...data, preset: (data.preset ?? 'balanced') as StreamSettingsView['preset'], logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
34
35
  }
35
36
 
36
37
  function upgradeView(value: unknown): StreamUpgradeView {
@@ -82,13 +83,14 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
82
83
  async read(): Promise<StreamSettingsView> {
83
84
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
84
85
  },
85
- async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
86
+ async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
86
87
  return settingsView(accepted(await connection.rpc.call(
87
88
  STREAM_SETTINGS_RPC_CHANNEL,
88
89
  STREAM_SETTINGS_RPC.write,
89
90
  {
90
91
  enabled: settings.enabled,
91
92
  controlScroll: settings.controlScroll,
93
+ preset: settings.preset,
92
94
  motionPreference: settings.motionPreference,
93
95
  thinkAutoExpand: settings.thinkAutoExpand,
94
96
  logarithmicFade: settings.logarithmicFade,