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.
- package/lib/client.js +1508 -362
- package/lib/client.js.map +1 -1
- package/lib/index.js +102 -8
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/SmoothStreamCard.tsx +28 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +216 -102
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +151 -11
- package/src/client/locales.ts +85 -0
- package/src/client/smooth-stream-card-controller.ts +7 -3
- package/src/client/smooth-stream-settings-api.ts +4 -2
- package/src/client/teleprompterGlide.ts +491 -52
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +439 -60
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +83 -8
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +34 -8
- package/src/settings.ts +11 -0
|
@@ -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
|
|
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 ||
|
|
177
|
-
|
|
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):
|
|
181
|
-
|
|
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
|
-
|
|
196
|
+
stopFrameTask()
|
|
197
|
+
return false
|
|
186
198
|
}
|
|
187
199
|
announcedSettled = false
|
|
188
200
|
if (lastFrame === null) {
|
|
189
201
|
lastFrame = now
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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: '
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
{
|
|
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 }),
|
package/src/settings-api.ts
CHANGED
|
@@ -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. */
|
package/src/settings-channel.ts
CHANGED
|
@@ -76,8 +76,21 @@ export function registerSettingsChannel(
|
|
|
76
76
|
channel: string,
|
|
77
77
|
handler: ConnectionRpcHandler,
|
|
78
78
|
): () => void {
|
|
79
|
-
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
+
|