dsh-smooth-stream 0.6.0 → 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.
@@ -30,12 +30,46 @@ export interface DebugMetrics {
30
30
  followRevealScale: number
31
31
  followFollowing: boolean
32
32
  followConstrained: boolean
33
+ /**
34
+ * Terminal-follow phase. `terminal-drain` (producer stopped, reveal queue
35
+ * still owes text) and `host-cascade` (host swapping status / collapsing
36
+ * Think / mounting the tail) need OPPOSITE corrections, so they must be
37
+ * distinguishable in the panel rather than inferred from `followActive`.
38
+ */
39
+ followTerminalPhase: FollowTerminalPhase
40
+ /** Physical owned bottom margin currently written into the DOM. */
41
+ followRunwayPx: number
42
+ /** Budget the terminal phase may spend, capped by the runway. */
43
+ followTerminalBudgetPx: number
44
+ /** `runwayPx - visibleReserve`: the term that turns into visible motion. */
45
+ followBaselineShiftPx: number
46
+ /** Screen-space reading-anchor delta this frame (null when unmeasurable). */
47
+ followAnchorDeltaPx: number | null
48
+ /** Reveal characters the smoother still owes after the producer stopped. */
49
+ followRemainingRevealChars: number
33
50
  scrollTop: number | null
34
51
  scrollHeight: number | null
35
52
  clientHeight: number | null
36
53
  lastUpdatedMs: number | null
37
54
  }
38
55
 
56
+ export type FollowTerminalPhase = 'live' | 'terminal-drain' | 'host-cascade' | 'natural'
57
+
58
+ /**
59
+ * Allocation-free read of the newest stream metric. The follower polls this
60
+ * every frame to learn whether the producer has stopped while reveal work
61
+ * remains, and `getSnapshot()` would allocate a fresh state object per frame
62
+ * just to answer one boolean.
63
+ */
64
+ export function readNewestStreamMetric(): { producerComplete: boolean; backlog: number } | null {
65
+ let newest: StreamMetric | undefined
66
+ for (const candidate of streamMetrics.values()) {
67
+ if (newest === undefined || candidate.updatedAt > newest.updatedAt) newest = candidate
68
+ }
69
+ if (newest === undefined) return null
70
+ return { producerComplete: newest.producerComplete, backlog: newest.backlog }
71
+ }
72
+
39
73
  export interface DebugRuntimeState {
40
74
  available: boolean
41
75
  enabled: boolean
@@ -70,6 +104,12 @@ interface StreamMetric {
70
104
  targetChars: number
71
105
  displayedChars: number
72
106
  active: boolean
107
+ /**
108
+ * The producer (model stream) has stopped even if the reveal queue still owes
109
+ * characters. The follower needs exactly this bit to tell a `terminal-drain`
110
+ * from live streaming, and it cannot be derived from `backlog > 0` alone.
111
+ */
112
+ producerComplete: boolean
73
113
  updatedAt: number
74
114
  }
75
115
 
@@ -85,6 +125,12 @@ interface FollowMetric {
85
125
  scrollHeight: number
86
126
  clientHeight: number
87
127
  active: boolean
128
+ terminalPhase: FollowTerminalPhase
129
+ runwayPx: number
130
+ terminalBudgetPx: number
131
+ baselineShiftPx: number
132
+ readingAnchorDeltaPx: number | null
133
+ remainingRevealChars: number
88
134
  updatedAt: number
89
135
  }
90
136
 
@@ -105,6 +151,12 @@ const EMPTY_METRICS: DebugMetrics = {
105
151
  followRevealScale: 1,
106
152
  followFollowing: false,
107
153
  followConstrained: false,
154
+ followTerminalPhase: 'live',
155
+ followRunwayPx: 0,
156
+ followTerminalBudgetPx: 0,
157
+ followBaselineShiftPx: 0,
158
+ followAnchorDeltaPx: null,
159
+ followRemainingRevealChars: 0,
108
160
  scrollTop: null,
109
161
  scrollHeight: null,
110
162
  clientHeight: null,
@@ -181,6 +233,12 @@ function currentMetrics(timestamp: number): DebugMetrics {
181
233
  followRevealScale: follow?.revealScale ?? 1,
182
234
  followFollowing: follow?.following ?? false,
183
235
  followConstrained: follow?.constrained ?? false,
236
+ followTerminalPhase: follow?.terminalPhase ?? 'live',
237
+ followRunwayPx: follow?.runwayPx ?? 0,
238
+ followTerminalBudgetPx: follow?.terminalBudgetPx ?? 0,
239
+ followBaselineShiftPx: follow?.baselineShiftPx ?? 0,
240
+ followAnchorDeltaPx: follow?.readingAnchorDeltaPx ?? null,
241
+ followRemainingRevealChars: follow?.remainingRevealChars ?? 0,
184
242
  scrollTop: follow?.scrollTop ?? null,
185
243
  scrollHeight: follow?.scrollHeight ?? null,
186
244
  clientHeight: follow?.clientHeight ?? null,
@@ -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,8 +183,10 @@ 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
189
+ && next.logarithmicFade === this.value.logarithmicFade
162
190
  && next.debugEnabled === this.value.debugEnabled
163
191
  && next.debugTuning === this.value.debugTuning
164
192
  ) return
@@ -172,6 +200,16 @@ class SettingsCell {
172
200
  return !this.pending && this.value.enabled
173
201
  }
174
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
+
175
213
  readonly getSnapshot = (): StreamSettings => this.value
176
214
 
177
215
  readonly subscribe = (listener: () => void): () => void => {
@@ -200,6 +238,55 @@ export function apply(ctx: ClientContext): void {
200
238
  () => settings.getSnapshot().controlScroll,
201
239
  )
202
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
+
203
290
  // The card talks to the plugin-owned loopback RPC, so the core settings
204
291
  // namespace allowlist cannot make it disappear. The stream still applies
205
292
  // with defaults when the optional Settings UI or Connection is absent.
@@ -264,18 +351,30 @@ export function apply(ctx: ClientContext): void {
264
351
  )
265
352
  return createElement(TypewriterAssistantNodeView, {
266
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 }),
267
359
  mode: config.mode,
268
- 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,
269
366
  revealCharsPerSec: config.revealCharsPerSec,
270
367
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
271
368
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
272
369
  thinkAutoExpand: preferences.thinkAutoExpand,
370
+ logarithmicFade: preferences.logarithmicFade,
273
371
  controlScroll: preferences.controlScroll,
274
372
  motionPreference: preferences.motionPreference,
275
373
  })
276
374
  }
277
375
  ctx.slots.inject('conversation.chat.node', () => {
278
376
  let releaseTakeover: (() => void) | undefined
377
+ let usedSwap = false
279
378
 
280
379
  const syncTakeover = (): void => {
281
380
  if (!settings.takeoverEnabled()) {
@@ -285,24 +384,67 @@ export function apply(ctx: ClientContext): void {
285
384
  }
286
385
  if (releaseTakeover !== undefined) return
287
386
  const unwrap = wrapAgentChatRows(ctx, useControlScroll)
288
- const unshadow = ctx.slots.register({
289
- name: 'conversation.chat.node',
290
- key: 'assistant-step',
291
- priority: -100,
292
- locale: 'conversation',
293
- registrant: 'dsh-smooth-stream',
294
- }, 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
+ }
295
425
  releaseTakeover = () => {
296
- // Stop observing slot changes before the shadow entry is removed.
426
+ // Stop observing slot changes before the takeover is removed.
297
427
  unwrap()
298
428
  unshadow()
299
429
  }
300
430
  }
301
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
+
302
443
  const unsubscribe = settings.subscribe(syncTakeover)
303
444
  syncTakeover()
304
445
  return () => {
305
446
  unsubscribe()
447
+ offSlotChange()
306
448
  releaseTakeover?.()
307
449
  }
308
450
  })
@@ -3,16 +3,65 @@
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'
14
62
  | 'motionForceReduced' | 'motionForceReducedHint'
15
63
  | 'thinkAutoExpand' | 'thinkAutoExpandHint'
64
+ | 'logarithmicFade' | 'logarithmicFadeHint'
16
65
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
17
66
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
18
67
  | 'debugLive' | 'debugIdle' | 'debugUnsaved'
@@ -21,6 +70,8 @@ export type SmoothStreamLocaleKey =
21
70
  | 'debugFps' | 'debugFrameTime' | 'debugBacklog' | 'debugRevealSpeed' | 'debugProgress'
22
71
  | 'debugFollowState' | 'debugFollowing' | 'debugReleased'
23
72
  | 'debugLag' | 'debugVelocity' | 'debugReserve' | 'debugCapacity' | 'debugAppliedScale'
73
+ | 'debugTerminalPhase' | 'debugOwnedRunway' | 'debugTerminalBudget' | 'debugBaselineShift'
74
+ | 'debugAnchorDelta' | 'debugPhaseLive' | 'debugPhaseDrain' | 'debugPhaseCascade' | 'debugPhaseNatural'
24
75
  | 'debugRevealMultiplier' | 'debugQueuePressure' | 'debugMaxReveal'
25
76
  | 'debugSpringStiffness' | 'debugSpringDamping' | 'debugSpringMass'
26
77
  | 'debugRunway' | 'debugReserveResponse' | 'debugBackpressureMin'
@@ -41,6 +92,14 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
41
92
  enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
42
93
  controlScroll: 'Take over scrolling',
43
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.',
44
103
  motionPreference: 'Motion',
45
104
  motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
46
105
  motionAuto: 'Follow system',
@@ -50,6 +109,8 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
50
109
  motionForceReduced: 'Always raw',
51
110
  motionForceReducedHint: 'Render raw text even when the system allows motion.',
52
111
  thinkAutoExpand: 'Auto-expand thinking',
112
+ logarithmicFade: 'Logarithmic fade',
113
+ logarithmicFadeHint: 'Fade new answer and expanded thinking text into view. Follows the motion preference.',
53
114
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
54
115
  debugEnabled: 'Show render diagnostics',
55
116
  debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
@@ -82,6 +143,15 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
82
143
  debugReserve: 'Reserve',
83
144
  debugCapacity: 'Capacity',
84
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',
85
155
  debugRevealMultiplier: 'Reveal multiplier',
86
156
  debugQueuePressure: 'Queue pressure',
87
157
  debugMaxReveal: 'Maximum reveal',
@@ -129,6 +199,14 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
129
199
  enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
130
200
  controlScroll: '接管滚动',
131
201
  controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
202
+ preset: '手感预设',
203
+ presetHint: '文字吐字节奏与视口跟随阻尼风格。',
204
+ presetRealtime: '激进快速 (realtime)',
205
+ presetRealtimeHint: '缓冲低、流速快,紧跟模型生成。',
206
+ presetBalanced: '均衡自然 (balanced)',
207
+ presetBalancedHint: '官方默认,平衡流速与节奏感。',
208
+ presetSilky: '极致丝滑 (silky)',
209
+ presetSilkyHint: '字符积压缓冲加大、阻尼更高、EMA 平滑度更好,视觉呼吸感最强。',
132
210
  motionPreference: '动效偏好',
133
211
  motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
134
212
  motionAuto: '跟随系统',
@@ -138,6 +216,8 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
138
216
  motionForceReduced: '始终原始',
139
217
  motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
140
218
  thinkAutoExpand: '自动展开思考',
219
+ logarithmicFade: '对数淡入',
220
+ logarithmicFadeHint: '让回答正文和展开的思考文字由淡变实,遵循动效偏好。',
141
221
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
142
222
  debugEnabled: '显示渲染调试面板',
143
223
  debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
@@ -170,6 +250,15 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
170
250
  debugReserve: '预留空间',
171
251
  debugCapacity: '安全容量',
172
252
  debugAppliedScale: '实际倍率',
253
+ debugTerminalPhase: '收尾阶段',
254
+ debugOwnedRunway: '实体留白',
255
+ debugTerminalBudget: '收尾预算',
256
+ debugBaselineShift: '基线位移',
257
+ debugAnchorDelta: '锚点位移',
258
+ debugPhaseLive: '流式中',
259
+ debugPhaseDrain: '收尾排空',
260
+ debugPhaseCascade: '宿主交接',
261
+ debugPhaseNatural: '自然态',
173
262
  debugRevealMultiplier: '揭示倍率',
174
263
  debugQueuePressure: '队列压力',
175
264
  debugMaxReveal: '最大揭示速度',
@@ -212,5 +301,6 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
212
301
  declare module '@deepseek-ai/dsh-client-ui-slots' {
213
302
  interface LocaleNamespaceMap {
214
303
  'settings.smoothStream': SmoothStreamLocaleKey
304
+ 'smoothStream.chat': SmoothStreamChatKey
215
305
  }
216
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,8 +25,10 @@ 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
31
+ logarithmicFade: boolean
29
32
  debugEnabled: boolean
30
33
  debugTuning: StreamDebugTuning
31
34
  debugAvailable: boolean
@@ -54,7 +57,7 @@ export class SmoothStreamCardController {
54
57
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
55
58
  private loaded: StreamSettingsView | undefined
56
59
  private loadedDebug: StreamDebugSettingsView | undefined
57
- private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
60
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
58
61
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
59
62
  private saving = false
60
63
  private failed = false
@@ -92,13 +95,15 @@ export class SmoothStreamCardController {
92
95
  hooks: { smoothStreamCard: this.store },
93
96
  edit: (patch) => {
94
97
  if (this.saving) return
95
- if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
98
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.preset !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
96
99
  this.stagedBase = {
97
100
  ...this.baseValues(),
98
101
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
99
102
  ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
103
+ ...(patch.preset === undefined ? {} : { preset: patch.preset }),
100
104
  ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
101
105
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
106
+ ...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
102
107
  }
103
108
  }
104
109
  if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
@@ -143,12 +148,14 @@ export class SmoothStreamCardController {
143
148
  }
144
149
  }
145
150
 
146
- private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
151
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
147
152
  return this.stagedBase ?? {
148
153
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
149
154
  controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
155
+ preset: this.loaded?.preset ?? DEFAULT_STREAM_SETTINGS.preset,
150
156
  motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
151
157
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
158
+ logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
152
159
  }
153
160
  }
154
161
 
@@ -24,12 +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'
30
+ || (data.logarithmicFade !== undefined && typeof data.logarithmicFade !== 'boolean')
29
31
  || typeof data.canUpgrade !== 'boolean') {
30
32
  throw new Error('dsh-smooth-stream: malformed settings response')
31
33
  }
32
- return data as unknown as StreamSettingsView
34
+ return { ...data, preset: (data.preset ?? 'balanced') as StreamSettingsView['preset'], logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
33
35
  }
34
36
 
35
37
  function upgradeView(value: unknown): StreamUpgradeView {
@@ -69,7 +71,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
69
71
  /** Narrow client contract consumed by the staged settings-card controller. */
70
72
  export interface SmoothStreamSettingsApi {
71
73
  read(): Promise<StreamSettingsView>
72
- write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
74
+ write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
73
75
  readDebug(): Promise<StreamDebugSettingsView>
74
76
  writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
75
77
  upgrade(): Promise<StreamUpgradeView>
@@ -81,15 +83,17 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
81
83
  async read(): Promise<StreamSettingsView> {
82
84
  return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
83
85
  },
84
- async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & 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> {
85
87
  return settingsView(accepted(await connection.rpc.call(
86
88
  STREAM_SETTINGS_RPC_CHANNEL,
87
89
  STREAM_SETTINGS_RPC.write,
88
90
  {
89
91
  enabled: settings.enabled,
90
92
  controlScroll: settings.controlScroll,
93
+ preset: settings.preset,
91
94
  motionPreference: settings.motionPreference,
92
95
  thinkAutoExpand: settings.thinkAutoExpand,
96
+ logarithmicFade: settings.logarithmicFade,
93
97
  ...(settings.debugEnabled === undefined || settings.debugTuning === undefined
94
98
  ? {}
95
99
  : { debugEnabled: settings.debugEnabled, debugTuning: settings.debugTuning }),