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.
@@ -11,6 +11,7 @@
11
11
  import { useLayoutEffect, type RefObject } from 'react'
12
12
  import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
13
13
  import { debugRuntime } from './debugRuntime.ts'
14
+ import { FrameCoordinator } from './FrameCoordinator.ts'
14
15
 
15
16
  interface TextRevealRecord {
16
17
  chars: readonly string[]
@@ -113,13 +114,20 @@ export function useProgressiveDomText(
113
114
 
114
115
  const pending = new Set<Text>()
115
116
  const internalWrites = new WeakMap<Text, string>()
116
- let rafId = 0
117
+ let frameTaskId: string | null = null
117
118
  let lastFrame: number | null = null
118
119
  let debt = 0
119
120
  let stopped = false
120
121
  let announcedSettled = false
121
122
  const streamId = `dom-${Math.random().toString(36).slice(2)}`
122
123
 
124
+ const coordinator = FrameCoordinator.forDocument()
125
+ const stopFrameTask = (): void => {
126
+ if (frameTaskId === null) return
127
+ coordinator.unregisterTask(frameTaskId)
128
+ frameTaskId = null
129
+ }
130
+
123
131
  const announceSettled = (): void => {
124
132
  lastFrame = null
125
133
  debt = 0
@@ -173,22 +181,25 @@ export function useProgressiveDomText(
173
181
  }
174
182
 
175
183
  const scheduleFrame = (): void => {
176
- if (stopped || pending.size === 0 || rafId !== 0) return
177
- rafId = requestAnimationFrame(frame)
184
+ if (stopped || pending.size === 0 || frameTaskId !== null) return
185
+ // Ride the shared document clock instead of a private rAF chain, so
186
+ // tool-output reveal cannot race the assistant reveal or the follower.
187
+ frameTaskId = coordinator.registerTask({
188
+ onSimulate: (_dtMs, now) => frame(now),
189
+ })
178
190
  }
179
191
 
180
- const frame = (now: number): void => {
181
- rafId = 0
182
- if (stopped) return
192
+ const frame = (now: number): boolean => {
193
+ if (stopped) return false
183
194
  if (pending.size === 0) {
184
195
  announceSettled()
185
- return
196
+ stopFrameTask()
197
+ return false
186
198
  }
187
199
  announcedSettled = false
188
200
  if (lastFrame === null) {
189
201
  lastFrame = now
190
- scheduleFrame()
191
- return
202
+ return true
192
203
  }
193
204
  const elapsed = Math.max(0, now - lastFrame)
194
205
  lastFrame = now
@@ -236,9 +247,14 @@ export function useProgressiveDomText(
236
247
  targetChars,
237
248
  displayedChars,
238
249
  active: pending.size > 0,
250
+ // This path reveals a node at a time and never observes the model
251
+ // stream's own end, so it cannot claim a terminal drain. Reporting
252
+ // `false` keeps the follower's terminal phase driven by the smoother
253
+ // that actually owns the completion signal.
254
+ producerComplete: false,
239
255
  })
240
256
  if (pending.size === 0) announceSettled()
241
- else scheduleFrame()
257
+ return pending.size > 0
242
258
  }
243
259
 
244
260
  visit(root, revealInitial)
@@ -269,7 +285,7 @@ export function useProgressiveDomText(
269
285
 
270
286
  return () => {
271
287
  stopped = true
272
- cancelAnimationFrame(rafId)
288
+ stopFrameTask()
273
289
  observer?.disconnect()
274
290
  for (const [node, record] of records) {
275
291
  const controlled = record.chars.slice(0, record.shown).join('')
@@ -434,18 +434,9 @@ export function useSmoothStreamContent(
434
434
  let revealSpeedCps: number
435
435
  let nextQueueDebt = 0
436
436
  if (producerComplete) {
437
- // Backpressure protects live layout. Once input has ended, retaining
438
- // that scale only makes a completed response keep typing onscreen.
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.
437
+ // Completion uses one bounded target velocity rather than the
438
+ // backlog-pressure curve. The latter changes speed as the queue
439
+ // shrinks and visibly kicks the final wrapped lines.
449
440
  const previousCps = lastDrainCpsRef.current > 0
450
441
  ? lastDrainCpsRef.current
451
442
  : Math.max(config.minCps, emaCpsRef.current)
@@ -495,7 +486,8 @@ export function useSmoothStreamContent(
495
486
  speedCps: revealSpeedCps,
496
487
  targetChars: targetCount,
497
488
  displayedChars: displayedCount,
498
- active: !producerComplete,
489
+ active: !producerComplete || backlog > 0,
490
+ producerComplete,
499
491
  })
500
492
 
501
493
  // Performance guard: while degraded and the reply is offscreen, skip
package/src/config.ts CHANGED
@@ -33,7 +33,7 @@ export interface StreamConfig {
33
33
  /** Defaults shared by the Host schema and the client-side fallback. */
34
34
  export const DEFAULT_STREAM_CONFIG: StreamConfig = {
35
35
  mode: 'typewriter',
36
- preset: 'balanced',
36
+ preset: 'silky',
37
37
  revealCharsPerSec: 80,
38
38
  scrollSpeedPxPerSec: 48,
39
39
  maxScrollSpeedPxPerSec: 1000,
package/src/plugin.ts CHANGED
@@ -57,6 +57,11 @@ export const Config: Schema<Config> = Schema.object({
57
57
  export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
58
58
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
59
59
  controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
60
+ preset: Schema.union([
61
+ Schema.const('realtime'),
62
+ Schema.const('balanced'),
63
+ Schema.const('silky'),
64
+ ] as const).default(DEFAULT_STREAM_SETTINGS.preset),
60
65
  motionPreference: Schema.union([
61
66
  Schema.const('auto'),
62
67
  Schema.const('force-smooth'),
@@ -102,16 +107,68 @@ export function apply(ctx: Context, config: Config): void {
102
107
  // the durable provider as the authority, but expose this one schema through
103
108
  // the plugin's own loopback-only connection channel instead.
104
109
  ctx.inject(['settings'], (settingsCtx) => {
105
- // 0.1.2 kernels dropped the `settingsNamespace()` helper — a validating
106
- // identity on ≤ 0.1.1 — and take the raw string, so the rc-era brand is
107
- // reproduced locally instead of statically importing a removed symbol.
108
- // The namespace is a compile-time constant matching the kernel's
109
- // /^[a-z][a-z0-9-]*$/ pattern.
110
- const settingsNamespace = STREAM_SETTINGS_NS as SettingsNamespace
111
- const scope = settingsCtx.settings.register(
110
+ // dsh 0.1.x: durable plugin-owned namespace registered on the settings
111
+ // service. dsh 0.2.x: `register` is gone — SettingsForms owns persistence
112
+ // through profile entry config — so serve the card read-only from the
113
+ // composition config until that migration lands, and keep the channel
114
+ // reachable so the card renders instead of dying as a 405.
115
+ const settingsSvc = settingsCtx.settings
116
+ const legacyRegister = (settingsSvc as { register?: unknown }).register
117
+ if (typeof legacyRegister !== 'function') {
118
+ const view02 = (): StreamSettingsView => ({
119
+ version: STREAM_PACKAGE_VERSION,
120
+ installation: inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind,
121
+ writable: false,
122
+ enabled: DEFAULT_STREAM_SETTINGS.enabled,
123
+ controlScroll: DEFAULT_STREAM_SETTINGS.controlScroll,
124
+ preset: config.preset,
125
+ motionPreference: DEFAULT_STREAM_SETTINGS.motionPreference,
126
+ thinkAutoExpand: DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
127
+ logarithmicFade: DEFAULT_STREAM_SETTINGS.logarithmicFade,
128
+ canUpgrade: false,
129
+ })
130
+ const handle02: ConnectionRpcHandler = async (endpoint) => {
131
+ if (endpoint === STREAM_SETTINGS_RPC.read) return { ok: true, value: view02() }
132
+ if (endpoint === STREAM_SETTINGS_RPC.debugRead) {
133
+ return { ok: true, value: { debugEnabled: DEFAULT_STREAM_SETTINGS.debugEnabled, tuning: DEFAULT_STREAM_SETTINGS.debugTuning } }
134
+ }
135
+ if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
136
+ const installation = inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME)
137
+ if (installation.kind !== 'npm') {
138
+ return { ok: false, error: { code: 'internal', message: 'smooth-stream is not an npm profile dependency', details: {} } }
139
+ }
140
+ return { ok: false, error: { code: 'internal', message: 'upgrade is unavailable until the 0.2.x settings migration lands', details: {} } }
141
+ }
142
+ return {
143
+ ok: false,
144
+ error: {
145
+ code: 'settings-rejected',
146
+ message: 'smooth-stream settings are read-only on dsh 0.2.x; durable storage moved to profile entry config',
147
+ details: { ns: STREAM_SETTINGS_NS },
148
+ },
149
+ }
150
+ }
151
+ // The connection service is not injectable from a plugin scope on 0.2.x,
152
+ // so mount through the web server fiber. registerSettingsChannel serves
153
+ // the route unfenced there (loopback + app token gate remain).
154
+ ctx.inject(['webServer'], (webCtx) => {
155
+ registerSettingsChannel(webCtx, STREAM_SETTINGS_RPC_CHANNEL, handle02)
156
+ })
157
+ return
158
+ }
159
+ const settingsNamespace = STREAM_SETTINGS_NS as SettingsNamespace
160
+ const scope = settingsCtx.settings.register(
112
161
  settingsNamespace,
113
162
  StreamSettingsSchema,
114
- { applies: 'live' },
163
+ {
164
+ // The install-time entry config is the composition base, so it resolves
165
+ // *below* the user layer: a stored pick still wins, while "the user
166
+ // never chose" keeps following the overlay (cordis.patch.yml / profile
167
+ // config). Keeping it out of the schema default is what makes those two
168
+ // states distinguishable at all.
169
+ base: { preset: config.preset },
170
+ applies: 'live',
171
+ },
115
172
  )
116
173
  settingsCtx.inject(['connection'], (connectionCtx) => {
117
174
  let upgrade: Promise<void> | undefined
@@ -125,6 +182,7 @@ export function apply(ctx: Context, config: Config): void {
125
182
  writable: connectionCtx.settings.writable,
126
183
  enabled: settings.enabled,
127
184
  controlScroll: settings.controlScroll,
185
+ preset: settings.preset ?? config.preset,
128
186
  motionPreference: settings.motionPreference,
129
187
  thinkAutoExpand: settings.thinkAutoExpand,
130
188
  logarithmicFade: settings.logarithmicFade,
@@ -194,12 +252,28 @@ export function apply(ctx: Context, config: Config): void {
194
252
  const next = payload as {
195
253
  enabled: boolean
196
254
  controlScroll: boolean
255
+ preset?: unknown
197
256
  motionPreference?: unknown
198
257
  thinkAutoExpand: boolean
199
258
  logarithmicFade?: unknown
200
259
  debugEnabled?: unknown
201
260
  debugTuning?: unknown
202
261
  }
262
+ if (
263
+ next.preset !== undefined
264
+ && next.preset !== 'realtime'
265
+ && next.preset !== 'balanced'
266
+ && next.preset !== 'silky'
267
+ ) {
268
+ return {
269
+ ok: false,
270
+ error: {
271
+ code: 'settings-rejected',
272
+ message: 'preset must be one of realtime | balanced | silky',
273
+ details: { ns: STREAM_SETTINGS_NS },
274
+ },
275
+ }
276
+ }
203
277
  if (
204
278
  next.motionPreference !== undefined
205
279
  && next.motionPreference !== 'auto'
@@ -239,6 +313,7 @@ export function apply(ctx: Context, config: Config): void {
239
313
  await scope.update({
240
314
  enabled: next.enabled,
241
315
  controlScroll: next.controlScroll,
316
+ ...(next.preset === undefined ? {} : { preset: next.preset }),
242
317
  ...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
243
318
  thinkAutoExpand: next.thinkAutoExpand,
244
319
  ...(next.logarithmicFade === undefined ? {} : { logarithmicFade: next.logarithmicFade }),
@@ -27,6 +27,8 @@ export interface StreamSettingsView {
27
27
  enabled: boolean
28
28
  /** Whether smooth-stream also owns conversation bottom-follow. */
29
29
  controlScroll: boolean
30
+ /** Smoothing preset for the reveal cadence and follow physics. */
31
+ preset: import('./settings.ts').StreamSmoothingPreset
30
32
  /** How the reveal honors the OS reduced-motion preference. */
31
33
  motionPreference: import('./settings.ts').StreamMotionPreference
32
34
  /** Current resolved preference. */
@@ -76,8 +76,21 @@ export function registerSettingsChannel(
76
76
  channel: string,
77
77
  handler: ConnectionRpcHandler,
78
78
  ): () => void {
79
- const connection = ctx.get('connection') as ChannelConnection | undefined
79
+ let connection: ChannelConnection | undefined
80
+ try {
81
+ connection = ctx.get('connection') as ChannelConnection | undefined
82
+ } catch {
83
+ // dsh 0.2.x: the connection service may be unresolvable from a plugin
84
+ // scope; the direct mount below then serves the channel unfenced.
85
+ connection = undefined
86
+ }
80
87
  return ctx.effect(() => {
88
+ try {
89
+ const direct = mountDirectRoute(ctx, connection, channel, handler)
90
+ if (direct !== undefined) return direct
91
+ } catch {
92
+ // fall through to the service path
93
+ }
81
94
  const viaService = tryServiceChannel(connection, channel, handler)
82
95
  if (viaService !== undefined) return viaService
83
96
  return mountDirectRoute(ctx, connection, channel, handler)
@@ -113,9 +126,15 @@ function tryServiceChannel(
113
126
  }
114
127
 
115
128
  /**
116
- * Mount the channel prefix on the Web server with the connection fence intact.
117
- * @throws when the kernel exposes neither the fence nor a Web server, so the
118
- * failure is reported instead of silently leaving the card unreachable.
129
+ * Mount the channel prefix on the Web server.
130
+ *
131
+ * With the connection service reachable the route sits behind the connection
132
+ * trust fence. On dsh 0.2.x the connection service is unresolvable from a
133
+ * plugin scope, so the route is then served unfenced (loopback + the app's
134
+ * own token gate only) with a warning — the same trade-off the skill center
135
+ * plugin ships on 0.2.x.
136
+ * @throws fail-closed when the connection service is reachable but cannot
137
+ * fence, so the channel is never exposed without the trust policy.
119
138
  */
120
139
  function mountDirectRoute(
121
140
  ctx: Context,
@@ -129,16 +148,23 @@ function mountDirectRoute(
129
148
  throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`)
130
149
  }
131
150
  if (typeof reject !== 'function') {
132
- throw new Error(
133
- `dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
134
- + 'cannot be mounted behind the connection trust fence',
151
+ if (connection !== undefined) {
152
+ throw new Error(
153
+ `dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
154
+ + 'cannot be mounted behind the connection trust fence',
155
+ )
156
+ }
157
+ console.warn(
158
+ `[dsh-smooth-stream] ${channel} mounted without the connection trust fence `
159
+ + '(dsh 0.2.x: the connection service is not injectable here); '
160
+ + 'loopback and the app token gate remain the only protections',
135
161
  )
136
162
  }
137
163
  return webServer.register({
138
164
  kind: 'prefix',
139
165
  path: channel,
140
166
  handler: async (req, res) => {
141
- const rejection = reject.call(connection, req)
167
+ const rejection = reject?.call(connection, req)
142
168
  if (rejection !== undefined) {
143
169
  res.writeHead(rejection)
144
170
  res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
package/src/settings.ts CHANGED
@@ -57,6 +57,11 @@ export type StreamMotionPreference =
57
57
  /** Always render raw text, ignoring the OS preference. */
58
58
  | 'force-reduced'
59
59
 
60
+ /**
61
+ * Smoothing cadence and damping profile.
62
+ */
63
+ export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
64
+
60
65
  /**
61
66
  * Preferences a user may set. Deliberately separate from {@link StreamConfig}
62
67
  * because the two change at different times: composition-time values go
@@ -73,6 +78,10 @@ export interface StreamSettings {
73
78
  * scroll ownership to the Harness; text reveal still runs.
74
79
  */
75
80
  controlScroll: boolean
81
+ /**
82
+ * Smoothing preset for the reveal cadence and follow physics.
83
+ */
84
+ preset: StreamSmoothingPreset
76
85
  /**
77
86
  * How the reveal honors the OS reduced-motion preference. `auto` preserves
78
87
  * the accessibility-first default; `force-smooth` keeps the engine on
@@ -98,9 +107,11 @@ export interface StreamSettings {
98
107
  export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
99
108
  enabled: true,
100
109
  controlScroll: true,
110
+ preset: 'silky',
101
111
  motionPreference: 'auto',
102
112
  thinkAutoExpand: true,
103
113
  logarithmicFade: true,
104
114
  debugEnabled: false,
105
115
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING,
106
116
  }
117
+