dsh-smooth-stream 0.4.1 → 0.4.2

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.
@@ -170,14 +170,24 @@ export function computeSettleDrain(config: StreamSmoothingPresetConfig, input: S
170
170
  return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
171
171
  }
172
172
 
173
- /** Fixed velocity that closes a producer-complete queue within its deadline. */
173
+ /**
174
+ * Fixed velocity that closes a producer-complete queue within its deadline.
175
+ * This completion-only target may exceed the live-stream flush ceiling.
176
+ */
174
177
  export function computeCompletionDrain(
175
178
  config: StreamSmoothingPresetConfig,
176
179
  backlog: number,
180
+ initialCps = 0,
177
181
  ): number {
178
182
  if (backlog <= 0) return 0
179
183
  const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
180
- const deadlineCps = (backlog * 1000) / drainTargetMs
184
+ const deadlineSeconds = drainTargetMs / 1000
185
+ const rampAreaSeconds = SETTLE_RAMP_TAU_S * (1 - Math.exp(
186
+ -deadlineSeconds / SETTLE_RAMP_TAU_S,
187
+ ))
188
+ const deadlineCps = (
189
+ backlog - Math.max(0, initialCps) * rampAreaSeconds
190
+ ) / Math.max(0.001, deadlineSeconds - rampAreaSeconds)
181
191
  return Math.max(
182
192
  deadlineCps,
183
193
  computeSettleDrain(config, { backlog, inputActive: false, settling: true }),
@@ -189,6 +199,8 @@ export function computeCompletionDrain(
189
199
  * this multiple of the steady rate, so the end never drags.
190
200
  */
191
201
  export const SETTLE_DRAIN_MULTIPLIER = 1.8
202
+ /** Time constant for ramping the completion-drain velocity up from streaming pace. */
203
+ export const SETTLE_RAMP_TAU_S = 0.09
192
204
 
193
205
  export interface RevealStepInput {
194
206
  readonly backlog: number
@@ -261,6 +273,14 @@ export interface UseSmoothStreamContentOptions {
261
273
  defaultCps?: number | undefined
262
274
  /** Written each commit with the live arrival-rate EMA for the follow lerp. */
263
275
  speedCpsRef?: { current: number } | undefined
276
+ /** Cumulative code points that have actually committed to the display. */
277
+ revealedCharsRef?: { current: number } | undefined
278
+ /**
279
+ * Called synchronously after each reveal commit lands in the DOM, so the
280
+ * conversation follower can re-run its geometry inside the same task and
281
+ * no intermediate (grown-but-uncompensated) frame can reach a paint.
282
+ */
283
+ onRevealCommit?: (() => void) | undefined
264
284
  /** Live multiplier from the follow spring when safe visual lag is filling. */
265
285
  revealScaleRef?: { current: number } | undefined
266
286
  }
@@ -282,7 +302,9 @@ export function useSmoothStreamContent(
282
302
  steadyCps,
283
303
  defaultCps,
284
304
  speedCpsRef,
305
+ revealedCharsRef,
285
306
  revealScaleRef,
307
+ onRevealCommit,
286
308
  }: UseSmoothStreamContentOptions = {},
287
309
  ): string {
288
310
  const config = PRESET_CONFIG[preset]
@@ -309,11 +331,16 @@ export function useSmoothStreamContent(
309
331
  const lastFrameTsRef = useRef<number | null>(null)
310
332
  const queueDebtRef = useRef(0)
311
333
  const settleCpsRef = useRef<number | null>(null)
334
+ const lastDrainCpsRef = useRef(0)
312
335
  const holdBackRef = useRef(shouldHoldBack)
313
336
  const speedOutRef = useRef(speedCpsRef)
314
337
  speedOutRef.current = speedCpsRef
338
+ const revealedCharsOutRef = useRef(revealedCharsRef)
339
+ revealedCharsOutRef.current = revealedCharsRef
315
340
  const revealScaleOutRef = useRef(revealScaleRef)
316
341
  revealScaleOutRef.current = revealScaleRef
342
+ const onRevealCommitOutRef = useRef(onRevealCommit)
343
+ onRevealCommitOutRef.current = onRevealCommit
317
344
  const inputCompleteRef = useRef(inputComplete)
318
345
  inputCompleteRef.current = inputComplete
319
346
  const streamIdRef = useRef(`stream-${Math.random().toString(36).slice(2)}`)
@@ -344,6 +371,7 @@ export function useSmoothStreamContent(
344
371
  displayedCountRef.current = chars.length
345
372
  queueDebtRef.current = 0
346
373
  settleCpsRef.current = null
374
+ lastDrainCpsRef.current = 0
347
375
  const speedOut = speedOutRef.current
348
376
  if (speedOut !== undefined) speedOut.current = seedCps
349
377
  setDisplayedContent(nextContent)
@@ -356,14 +384,16 @@ export function useSmoothStreamContent(
356
384
  [seedCps, stopFrameLoop],
357
385
  )
358
386
 
359
- // Producer completion is authoritative. Publish the accumulated source in
360
- // the same commit instead of leaving a visible typewriter tail after the
361
- // Agent has already stopped; the conversation follower still glides any
362
- // resulting layout height before paint.
363
- useLayoutEffect(() => {
364
- if (!enabled || !inputComplete || displayedContentRef.current === content) return
365
- syncImmediate(content)
366
- }, [content, enabled, inputComplete, syncImmediate])
387
+ // Producer completion used to publish the accumulated source in one commit
388
+ // here. That single commit is a visible teleport whenever a fast stream
389
+ // ends with standing backlog — hundreds of chars become thousands of px in
390
+ // one frame, exactly the end-of-render jerk this plugin fights. The frame
391
+ // loop's producer-complete branch already drains the queue at a bounded
392
+ // constant velocity (computeCompletionDrain), which finishes near its
393
+ // configured deadline and glides every px through the spring. Nothing is left
394
+ // "typing" after the Agent stops; the tail just closes at drain speed.
395
+ //
396
+ // Non-append-only edits and disabled streams still sync immediately below.
367
397
 
368
398
  const startFrameLoop = useCallback(() => {
369
399
  if (rafRef.current !== null) return
@@ -406,14 +436,31 @@ export function useSmoothStreamContent(
406
436
  if (producerComplete) {
407
437
  // Backpressure protects live layout. Once input has ended, retaining
408
438
  // that scale only makes a completed response keep typing onscreen.
409
- // Keep one drain velocity for the whole completion tail. Recomputing
410
- // it from the shrinking backlog creates an exponential slow tail.
411
- const settleCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog)
412
- settleCpsRef.current = settleCps
439
+ // Keep one TARGET velocity for the whole completion tail — recomputing
440
+ // it from the shrinking backlog creates an exponential slow tail —
441
+ // but RAMP the effective velocity up from the last reveal speed over
442
+ // ~3 frames: an instant jump from streaming pace to max flush is
443
+ // itself a visible jerk at exactly the moment the reader is watching
444
+ // the reply finish.
445
+ // The pre-drain reveal speed is the streaming EMA, not the debug seed.
446
+ // Seeding the ramp from the 35cps default when the reply ran at the
447
+ // max-flush ceiling stretched a 420ms tail past the 800ms announcer
448
+ // step and the visible Markdown mutated across it.
449
+ const previousCps = lastDrainCpsRef.current > 0
450
+ ? lastDrainCpsRef.current
451
+ : Math.max(config.minCps, emaCpsRef.current)
452
+ const drainTargetCps = settleCpsRef.current
453
+ ?? computeCompletionDrain(config, backlog, previousCps)
454
+ settleCpsRef.current = drainTargetCps
455
+ const rampedCps = previousCps
456
+ + (drainTargetCps - previousCps) * (1 - Math.exp(-dtSeconds / SETTLE_RAMP_TAU_S))
457
+ const settleCps = Math.min(drainTargetCps, Math.max(previousCps, rampedCps))
458
+ lastDrainCpsRef.current = Math.min(drainTargetCps, rampedCps)
413
459
  const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds
414
460
  revealChars = Math.min(backlog, Math.floor(accumulated))
415
461
  revealSpeedCps = settleCps
416
462
  nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars
463
+ if (revealChars >= backlog) lastDrainCpsRef.current = 0
417
464
  } else if (steadyCps !== undefined) {
418
465
  const step = computeRevealStep(
419
466
  config,
@@ -486,7 +533,16 @@ export function useSmoothStreamContent(
486
533
  }
487
534
 
488
535
  rafRef.current = requestAnimationFrame(tick)
489
- }, [config, stopFrameLoop, steadyCps])
536
+ }, [config, seedCps, stopFrameLoop, steadyCps])
537
+
538
+ // Run AFTER the commit's DOM mutations, before paint: the follower's
539
+ // same-task correction hook.
540
+ useLayoutEffect(() => {
541
+ const revealedCharsOut = revealedCharsOutRef.current
542
+ if (revealedCharsOut !== undefined) revealedCharsOut.current = displayedCountRef.current
543
+ if (displayedContent === '') return
544
+ onRevealCommitOutRef.current?.()
545
+ }, [displayedContent])
490
546
 
491
547
  useEffect(() => {
492
548
  startFrameLoopRef.current = startFrameLoop
package/src/settings.ts CHANGED
@@ -40,7 +40,7 @@ export const DEFAULT_STREAM_DEBUG_TUNING: StreamDebugTuning = {
40
40
  springStiffness: 130,
41
41
  springDamping: 24,
42
42
  springMass: 1,
43
- runwayPx: 48,
43
+ runwayPx: 72,
44
44
  reserveResponseMs: 180,
45
45
  backpressureMinScale: 0.55,
46
46
  }