dsh-smooth-stream 0.6.0 → 0.6.1

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/index.js CHANGED
@@ -136,6 +136,196 @@ function updateNpmProfilePackage(profileDir, packageName) {
136
136
  });
137
137
  }
138
138
  //#endregion
139
+ //#region src/settings-channel.ts
140
+ /** Largest request body this channel buffers; settings payloads are tiny. */
141
+ const MAX_REQUEST_BYTES = 1 << 20;
142
+ /** Endpoint segment shape accepted by the Connection router, mirrored here. */
143
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
144
+ /** Envelope discriminator the browser caller sends. */
145
+ const CLIENT_REQUEST = "client-request";
146
+ /** Envelope discriminator the browser caller expects back. */
147
+ const SERVER_RESPONSE = "server-response";
148
+ /**
149
+ * Register the settings RPC channel on the most capable path the kernel has.
150
+ * The registration is an effect of `ctx`, so the caller's fiber owns the
151
+ * channel's lifetime on either path.
152
+ * @param ctx - Host context carrying `connection` and, when composed, `webServer`.
153
+ * @param channel - absolute channel prefix, e.g. `/smooth-stream`.
154
+ * @param handler - decoded endpoint handler for every endpoint on the channel.
155
+ * @returns Disposer that withdraws the channel.
156
+ */
157
+ function registerSettingsChannel(ctx, channel, handler) {
158
+ const connection = ctx.get("connection");
159
+ return ctx.effect(() => {
160
+ const viaService = tryServiceChannel(connection, channel, handler);
161
+ if (viaService !== void 0) return viaService;
162
+ return mountDirectRoute(ctx, connection, channel, handler);
163
+ }, `dsh-smooth-stream: ${channel} RPC channel`);
164
+ }
165
+ /**
166
+ * Try the service-owned registration, which is the only path that also carries
167
+ * the kernel's own channel policy.
168
+ * @returns Disposer on success, `undefined` when the kernel cannot mount it.
169
+ */
170
+ function tryServiceChannel(connection, channel, handler) {
171
+ const rpc = connection?.rpc;
172
+ if (rpc === void 0 || typeof rpc.handle !== "function") return void 0;
173
+ try {
174
+ const release = rpc.handle(channel, handler, { authority: "loopback" });
175
+ if (typeof release !== "function") return void 0;
176
+ return () => {
177
+ release();
178
+ };
179
+ } catch (error) {
180
+ console.warn(`[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; falling back to a directly registered route`, error);
181
+ return;
182
+ }
183
+ }
184
+ /**
185
+ * Mount the channel prefix on the Web server with the connection fence intact.
186
+ * @throws when the kernel exposes neither the fence nor a Web server, so the
187
+ * failure is reported instead of silently leaving the card unreachable.
188
+ */
189
+ function mountDirectRoute(ctx, connection, channel, handler) {
190
+ const webServer = ctx.get("webServer");
191
+ const reject = connection?.requestRejection;
192
+ if (webServer === void 0 || typeof webServer.register !== "function") throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`);
193
+ if (typeof reject !== "function") throw new Error(`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} cannot be mounted behind the connection trust fence`);
194
+ return webServer.register({
195
+ kind: "prefix",
196
+ path: channel,
197
+ handler: async (req, res) => {
198
+ const rejection = reject.call(connection, req);
199
+ if (rejection !== void 0) {
200
+ res.writeHead(rejection);
201
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
202
+ return;
203
+ }
204
+ await answer(req, res, channel, handler);
205
+ }
206
+ });
207
+ }
208
+ /**
209
+ * Decode one request envelope, dispatch it, and write the decoded reply.
210
+ * Mirrors the Connection router's own carrier rules: method and endpoint must
211
+ * agree, the body must be JSON, and only endpoint failures are results while
212
+ * transport failures are statuses.
213
+ */
214
+ async function answer(req, res, channel, handler) {
215
+ const endpoint = endpointOf(channel, req.url);
216
+ if (req.method !== "POST" || endpoint === void 0) {
217
+ res.writeHead(404);
218
+ res.end("not found");
219
+ return;
220
+ }
221
+ if ((req.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
222
+ res.writeHead(415);
223
+ res.end("content type must be application/json");
224
+ return;
225
+ }
226
+ const body = await readBody(req);
227
+ if (body.kind === "too-large") {
228
+ res.writeHead(413);
229
+ res.end("request body too large");
230
+ return;
231
+ }
232
+ if (body.kind === "invalid") {
233
+ res.writeHead(400);
234
+ res.end("body is not JSON");
235
+ return;
236
+ }
237
+ const envelope = requestEnvelope(body.value);
238
+ if (envelope === void 0) {
239
+ write(res, rpcIdOf(body.value), {
240
+ ok: false,
241
+ error: {
242
+ code: "gateway/bad-request",
243
+ message: "invalid client-request message",
244
+ details: { issues: [] }
245
+ }
246
+ });
247
+ return;
248
+ }
249
+ if (envelope.method !== endpoint) {
250
+ write(res, envelope.rpcId, {
251
+ ok: false,
252
+ error: {
253
+ code: "gateway/bad-request",
254
+ message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
255
+ details: { issues: [] }
256
+ }
257
+ });
258
+ return;
259
+ }
260
+ const controller = new AbortController();
261
+ res.on("close", () => {
262
+ controller.abort();
263
+ });
264
+ try {
265
+ write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal));
266
+ } catch (error) {
267
+ res.writeHead(500);
268
+ res.end(`handler failure: ${String(error)}`);
269
+ }
270
+ }
271
+ /**
272
+ * Resolve the channel-relative endpoint for one request path.
273
+ * @returns The endpoint, or `undefined` when the path is outside the channel
274
+ * or carries a segment the Connection router would refuse.
275
+ */
276
+ function endpointOf(channel, rawUrl) {
277
+ const pathname = new URL(rawUrl ?? "/", "http://dsh.internal").pathname;
278
+ if (!pathname.startsWith(`${channel}/`)) return void 0;
279
+ const endpoint = pathname.slice(channel.length + 1);
280
+ if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT.test(segment))) return;
281
+ return endpoint;
282
+ }
283
+ /** Structure one decoded request, rejecting anything the caller could not have sent. */
284
+ function requestEnvelope(value) {
285
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
286
+ const record = value;
287
+ if (record.type !== CLIENT_REQUEST) return void 0;
288
+ if (typeof record.rpcId !== "string" || typeof record.method !== "string") return void 0;
289
+ return {
290
+ rpcId: record.rpcId,
291
+ method: record.method,
292
+ payload: record.payload
293
+ };
294
+ }
295
+ /** Recover the correlation id of a malformed envelope so the caller can match it. */
296
+ function rpcIdOf(value) {
297
+ const raw = value?.rpcId;
298
+ return typeof raw === "string" ? raw : "invalid-request";
299
+ }
300
+ /** Write one decoded reply in the envelope the browser caller parses. */
301
+ function write(res, rpcId, result) {
302
+ res.writeHead(200, { "content-type": "application/json" });
303
+ res.end(JSON.stringify({
304
+ type: SERVER_RESPONSE,
305
+ rpcId,
306
+ result
307
+ }));
308
+ }
309
+ /** Buffer one request body, refusing both malformed JSON and unbounded input. */
310
+ async function readBody(req) {
311
+ const chunks = [];
312
+ let bytes = 0;
313
+ for await (const chunk of req) {
314
+ const buffer = chunk;
315
+ bytes += buffer.length;
316
+ if (bytes > MAX_REQUEST_BYTES) return { kind: "too-large" };
317
+ chunks.push(buffer);
318
+ }
319
+ try {
320
+ return {
321
+ kind: "value",
322
+ value: JSON.parse(Buffer.concat(chunks).toString("utf8"))
323
+ };
324
+ } catch {
325
+ return { kind: "invalid" };
326
+ }
327
+ }
328
+ //#endregion
139
329
  //#region src/settings-api.ts
140
330
  /** Shared wire vocabulary for the plugin-owned settings RPC channel. */
141
331
  /** Dedicated, loopback-only RPC channel registered by the Host half. */
@@ -166,6 +356,7 @@ const DEFAULT_STREAM_SETTINGS = {
166
356
  controlScroll: true,
167
357
  motionPreference: "auto",
168
358
  thinkAutoExpand: true,
359
+ logarithmicFade: true,
169
360
  debugEnabled: false,
170
361
  debugTuning: {
171
362
  revealScale: 1,
@@ -207,6 +398,7 @@ const StreamSettingsSchema = Schema.object({
207
398
  Schema.const("force-reduced")
208
399
  ]).default(DEFAULT_STREAM_SETTINGS.motionPreference),
209
400
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
401
+ logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
210
402
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
211
403
  debugTuning: Schema.object({
212
404
  revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
@@ -249,6 +441,7 @@ function apply(ctx, config) {
249
441
  controlScroll: settings.controlScroll,
250
442
  motionPreference: settings.motionPreference,
251
443
  thinkAutoExpand: settings.thinkAutoExpand,
444
+ logarithmicFade: settings.logarithmicFade,
252
445
  canUpgrade: installation.kind === "npm"
253
446
  };
254
447
  };
@@ -296,6 +489,14 @@ function apply(ctx, config) {
296
489
  details: { ns: STREAM_SETTINGS_NS }
297
490
  }
298
491
  };
492
+ if (next.logarithmicFade !== void 0 && typeof next.logarithmicFade !== "boolean") return {
493
+ ok: false,
494
+ error: {
495
+ code: "settings-rejected",
496
+ message: "logarithmicFade must be a boolean",
497
+ details: { ns: STREAM_SETTINGS_NS }
498
+ }
499
+ };
299
500
  const hasDebug = next.debugEnabled !== void 0 || next.debugTuning !== void 0;
300
501
  if (hasDebug && (typeof next.debugEnabled !== "boolean" || !validDebugTuning(next.debugTuning))) return {
301
502
  ok: false,
@@ -310,6 +511,7 @@ function apply(ctx, config) {
310
511
  controlScroll: next.controlScroll,
311
512
  ...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
312
513
  thinkAutoExpand: next.thinkAutoExpand,
514
+ ...next.logarithmicFade === void 0 ? {} : { logarithmicFade: next.logarithmicFade },
313
515
  ...hasDebug ? {
314
516
  debugEnabled: next.debugEnabled,
315
517
  debugTuning: next.debugTuning
@@ -427,7 +629,7 @@ function apply(ctx, config) {
427
629
  }
428
630
  };
429
631
  };
430
- connectionCtx.effect(() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: "loopback" }), "dsh-smooth-stream: settings RPC");
632
+ registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle);
431
633
  });
432
634
  });
433
635
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,160 @@
1
+ /* Shared buckets change paint only; original DOM, layout and colors survive. */
2
+ .scope::highlight(dsh-smooth-stream-log-fade-0),
3
+ .scope *::highlight(dsh-smooth-stream-log-fade-0) {
4
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 0.00000%, transparent);
5
+ }
6
+
7
+ .scope::highlight(dsh-smooth-stream-log-fade-1),
8
+ .scope *::highlight(dsh-smooth-stream-log-fade-1) {
9
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 3.22581%, transparent);
10
+ }
11
+
12
+ .scope::highlight(dsh-smooth-stream-log-fade-2),
13
+ .scope *::highlight(dsh-smooth-stream-log-fade-2) {
14
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 6.45161%, transparent);
15
+ }
16
+
17
+ .scope::highlight(dsh-smooth-stream-log-fade-3),
18
+ .scope *::highlight(dsh-smooth-stream-log-fade-3) {
19
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 9.67742%, transparent);
20
+ }
21
+
22
+ .scope::highlight(dsh-smooth-stream-log-fade-4),
23
+ .scope *::highlight(dsh-smooth-stream-log-fade-4) {
24
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 12.90323%, transparent);
25
+ }
26
+
27
+ .scope::highlight(dsh-smooth-stream-log-fade-5),
28
+ .scope *::highlight(dsh-smooth-stream-log-fade-5) {
29
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 16.12903%, transparent);
30
+ }
31
+
32
+ .scope::highlight(dsh-smooth-stream-log-fade-6),
33
+ .scope *::highlight(dsh-smooth-stream-log-fade-6) {
34
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 19.35484%, transparent);
35
+ }
36
+
37
+ .scope::highlight(dsh-smooth-stream-log-fade-7),
38
+ .scope *::highlight(dsh-smooth-stream-log-fade-7) {
39
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 22.58065%, transparent);
40
+ }
41
+
42
+ .scope::highlight(dsh-smooth-stream-log-fade-8),
43
+ .scope *::highlight(dsh-smooth-stream-log-fade-8) {
44
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 25.80645%, transparent);
45
+ }
46
+
47
+ .scope::highlight(dsh-smooth-stream-log-fade-9),
48
+ .scope *::highlight(dsh-smooth-stream-log-fade-9) {
49
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 29.03226%, transparent);
50
+ }
51
+
52
+ .scope::highlight(dsh-smooth-stream-log-fade-10),
53
+ .scope *::highlight(dsh-smooth-stream-log-fade-10) {
54
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 32.25806%, transparent);
55
+ }
56
+
57
+ .scope::highlight(dsh-smooth-stream-log-fade-11),
58
+ .scope *::highlight(dsh-smooth-stream-log-fade-11) {
59
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 35.48387%, transparent);
60
+ }
61
+
62
+ .scope::highlight(dsh-smooth-stream-log-fade-12),
63
+ .scope *::highlight(dsh-smooth-stream-log-fade-12) {
64
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 38.70968%, transparent);
65
+ }
66
+
67
+ .scope::highlight(dsh-smooth-stream-log-fade-13),
68
+ .scope *::highlight(dsh-smooth-stream-log-fade-13) {
69
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 41.93548%, transparent);
70
+ }
71
+
72
+ .scope::highlight(dsh-smooth-stream-log-fade-14),
73
+ .scope *::highlight(dsh-smooth-stream-log-fade-14) {
74
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 45.16129%, transparent);
75
+ }
76
+
77
+ .scope::highlight(dsh-smooth-stream-log-fade-15),
78
+ .scope *::highlight(dsh-smooth-stream-log-fade-15) {
79
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 48.38710%, transparent);
80
+ }
81
+
82
+ .scope::highlight(dsh-smooth-stream-log-fade-16),
83
+ .scope *::highlight(dsh-smooth-stream-log-fade-16) {
84
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 51.61290%, transparent);
85
+ }
86
+
87
+ .scope::highlight(dsh-smooth-stream-log-fade-17),
88
+ .scope *::highlight(dsh-smooth-stream-log-fade-17) {
89
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 54.83871%, transparent);
90
+ }
91
+
92
+ .scope::highlight(dsh-smooth-stream-log-fade-18),
93
+ .scope *::highlight(dsh-smooth-stream-log-fade-18) {
94
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 58.06452%, transparent);
95
+ }
96
+
97
+ .scope::highlight(dsh-smooth-stream-log-fade-19),
98
+ .scope *::highlight(dsh-smooth-stream-log-fade-19) {
99
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 61.29032%, transparent);
100
+ }
101
+
102
+ .scope::highlight(dsh-smooth-stream-log-fade-20),
103
+ .scope *::highlight(dsh-smooth-stream-log-fade-20) {
104
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 64.51613%, transparent);
105
+ }
106
+
107
+ .scope::highlight(dsh-smooth-stream-log-fade-21),
108
+ .scope *::highlight(dsh-smooth-stream-log-fade-21) {
109
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 67.74194%, transparent);
110
+ }
111
+
112
+ .scope::highlight(dsh-smooth-stream-log-fade-22),
113
+ .scope *::highlight(dsh-smooth-stream-log-fade-22) {
114
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 70.96774%, transparent);
115
+ }
116
+
117
+ .scope::highlight(dsh-smooth-stream-log-fade-23),
118
+ .scope *::highlight(dsh-smooth-stream-log-fade-23) {
119
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 74.19355%, transparent);
120
+ }
121
+
122
+ .scope::highlight(dsh-smooth-stream-log-fade-24),
123
+ .scope *::highlight(dsh-smooth-stream-log-fade-24) {
124
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 77.41935%, transparent);
125
+ }
126
+
127
+ .scope::highlight(dsh-smooth-stream-log-fade-25),
128
+ .scope *::highlight(dsh-smooth-stream-log-fade-25) {
129
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 80.64516%, transparent);
130
+ }
131
+
132
+ .scope::highlight(dsh-smooth-stream-log-fade-26),
133
+ .scope *::highlight(dsh-smooth-stream-log-fade-26) {
134
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 83.87097%, transparent);
135
+ }
136
+
137
+ .scope::highlight(dsh-smooth-stream-log-fade-27),
138
+ .scope *::highlight(dsh-smooth-stream-log-fade-27) {
139
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 87.09677%, transparent);
140
+ }
141
+
142
+ .scope::highlight(dsh-smooth-stream-log-fade-28),
143
+ .scope *::highlight(dsh-smooth-stream-log-fade-28) {
144
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 90.32258%, transparent);
145
+ }
146
+
147
+ .scope::highlight(dsh-smooth-stream-log-fade-29),
148
+ .scope *::highlight(dsh-smooth-stream-log-fade-29) {
149
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 93.54839%, transparent);
150
+ }
151
+
152
+ .scope::highlight(dsh-smooth-stream-log-fade-30),
153
+ .scope *::highlight(dsh-smooth-stream-log-fade-30) {
154
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 96.77419%, transparent);
155
+ }
156
+
157
+ .scope::highlight(dsh-smooth-stream-log-fade-31),
158
+ .scope *::highlight(dsh-smooth-stream-log-fade-31) {
159
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 100.00000%, transparent);
160
+ }
@@ -84,6 +84,19 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
84
84
  </span>
85
85
  <span className={css.hint}>{t('controlScrollHint')}</span>
86
86
  </label>
87
+ <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
88
+ <span className={css.fieldHead}>
89
+ <span className={css.label}>{t('logarithmicFade')}</span>
90
+ <input
91
+ type="checkbox"
92
+ className={css.toggle}
93
+ checked={state.logarithmicFade}
94
+ disabled={!state.writable || state.saving || !state.enabled}
95
+ onChange={(event) => { props.edit({ logarithmicFade: event.target.checked }) }}
96
+ />
97
+ </span>
98
+ <span className={css.hint}>{t('logarithmicFadeHint')}</span>
99
+ </label>
87
100
  {/* Motion preference is a radio group, so this row is a div:
88
101
  nesting the choice labels inside a field <label> would be
89
102
  illegal HTML (label within label) and browsers route the
@@ -6,6 +6,7 @@ import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
6
6
  import { notifyFollowCommit } from './teleprompterGlide.ts'
7
7
  import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
8
8
  import { useFpsGuard } from './useFpsGuard.ts'
9
+ import { useLogarithmicFade } from './useLogarithmicFade.ts'
9
10
  import { FollowHost } from './FollowHost.tsx'
10
11
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
11
12
  import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
@@ -45,6 +46,7 @@ function useMotionReduced(preference: StreamMotionPreference): boolean {
45
46
 
46
47
  interface AnimatedMarkdownTextProps extends MarkdownProps {
47
48
  streaming: boolean
49
+ logarithmicFade: boolean
48
50
  /** Whether the resolved reduced-motion gate keeps the reveal engine off. */
49
51
  motionReduced: boolean
50
52
  /** True on the last text block: that block owns conversation follow. */
@@ -307,6 +309,7 @@ function AnimatedMarkdownText({
307
309
  labels,
308
310
  fileMentions,
309
311
  streaming,
312
+ logarithmicFade,
310
313
  motionReduced,
311
314
  ownFollow,
312
315
  followSpeedCpsRef,
@@ -337,6 +340,7 @@ function AnimatedMarkdownText({
337
340
  })
338
341
  const shown = reduced ? text : displayed
339
342
  const live = typing && !reduced
343
+ useLogarithmicFade(followRootRef, logarithmicFade && !reduced, live, speedCpsRef)
340
344
 
341
345
  useEffect(() => {
342
346
  const root = followRootRef.current
@@ -494,6 +498,7 @@ function AnimatedReasoning({
494
498
  preset,
495
499
  thinkAutoExpand,
496
500
  motionReduced,
501
+ logarithmicFade,
497
502
  shouldHoldBack,
498
503
  followSpeedCpsRef,
499
504
  followRevealScaleRef,
@@ -504,6 +509,7 @@ function AnimatedReasoning({
504
509
  preset: StreamSmoothingPreset
505
510
  thinkAutoExpand: boolean
506
511
  motionReduced: boolean
512
+ logarithmicFade: boolean
507
513
  shouldHoldBack: () => boolean
508
514
  followSpeedCpsRef?: { current: number } | undefined
509
515
  followRevealScaleRef?: { current: number } | undefined
@@ -513,6 +519,9 @@ function AnimatedReasoning({
513
519
  const [expanded, setExpanded] = useState(running && thinkAutoExpand)
514
520
  const [autoClosed, setAutoClosed] = useState(false)
515
521
  const summaryRef = useRef<HTMLSpanElement>(null)
522
+ const fadeRootRef = useRef<HTMLDivElement>(null)
523
+ const localFadeSpeedRef = useRef(35)
524
+ const fadeSpeedRef = followSpeedCpsRef ?? localFadeSpeedRef
516
525
  const commitAnchorRef = useRef<HTMLDivElement>(null)
517
526
  // The running→false flip is the AUTO-close: it collapses instantly and the
518
527
  // follower's settle spring absorbs the height step. A later manual toggle
@@ -521,12 +530,13 @@ function AnimatedReasoning({
521
530
  enabled: running && !reduced,
522
531
  preset,
523
532
  shouldHoldBack,
524
- speedCpsRef: followSpeedCpsRef,
533
+ speedCpsRef: fadeSpeedRef,
525
534
  revealScaleRef: followRevealScaleRef,
526
535
  onRevealCommit: () => { notifyFollowCommit(commitAnchorRef.current) },
527
536
  })
528
537
  const shown = running && !reduced ? displayed : text
529
538
  const summary = running ? latestLine(shown) : firstLine(text)
539
+ useLogarithmicFade(fadeRootRef, logarithmicFade && !reduced && expanded, running, fadeSpeedRef)
530
540
 
531
541
  useLayoutEffect(() => {
532
542
  // Only the running state owns disclosure while auto-expand is on; with it
@@ -576,7 +586,7 @@ function AnimatedReasoning({
576
586
  </>
577
587
  )}
578
588
  >
579
- <div className={css.thinkBody}>{shown}</div>
589
+ <div ref={fadeRootRef} className={css.thinkBody}>{shown}</div>
580
590
  </AnimatedDisclosure>
581
591
  </div>
582
592
  </div>
@@ -600,6 +610,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
600
610
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
601
611
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
602
612
  thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
613
+ logarithmicFade = DEFAULT_STREAM_SETTINGS.logarithmicFade,
603
614
  controlScroll = true,
604
615
  motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
605
616
  node,
@@ -616,6 +627,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
616
627
  scrollSpeedPxPerSec?: number
617
628
  maxScrollSpeedPxPerSec?: number
618
629
  thinkAutoExpand?: boolean
630
+ logarithmicFade?: boolean
619
631
  controlScroll?: boolean
620
632
  motionPreference?: StreamMotionPreference
621
633
  }) {
@@ -704,6 +716,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
704
716
  labels={markdownLabels}
705
717
  fileMentions={mentions}
706
718
  streaming={streaming}
719
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
707
720
  motionReduced={reduced}
708
721
  ownFollow={!streaming && index === lastFollow}
709
722
  followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
@@ -724,6 +737,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
724
737
  running={streaming && index === last}
725
738
  preset={preset}
726
739
  thinkAutoExpand={thinkAutoExpand}
740
+ logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
727
741
  motionReduced={reduced}
728
742
  shouldHoldBack={shouldHoldBack}
729
743
  followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
@@ -159,6 +159,7 @@ class SettingsCell {
159
159
  && next.controlScroll === this.value.controlScroll
160
160
  && next.motionPreference === this.value.motionPreference
161
161
  && next.thinkAutoExpand === this.value.thinkAutoExpand
162
+ && next.logarithmicFade === this.value.logarithmicFade
162
163
  && next.debugEnabled === this.value.debugEnabled
163
164
  && next.debugTuning === this.value.debugTuning
164
165
  ) return
@@ -270,6 +271,7 @@ export function apply(ctx: ClientContext): void {
270
271
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
271
272
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
272
273
  thinkAutoExpand: preferences.thinkAutoExpand,
274
+ logarithmicFade: preferences.logarithmicFade,
273
275
  controlScroll: preferences.controlScroll,
274
276
  motionPreference: preferences.motionPreference,
275
277
  })
@@ -13,6 +13,7 @@ export type SmoothStreamLocaleKey =
13
13
  | 'motionForceSmooth' | 'motionForceSmoothHint'
14
14
  | 'motionForceReduced' | 'motionForceReducedHint'
15
15
  | 'thinkAutoExpand' | 'thinkAutoExpandHint'
16
+ | 'logarithmicFade' | 'logarithmicFadeHint'
16
17
  | 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
17
18
  | 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
18
19
  | 'debugLive' | 'debugIdle' | 'debugUnsaved'
@@ -50,6 +51,8 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
50
51
  motionForceReduced: 'Always raw',
51
52
  motionForceReducedHint: 'Render raw text even when the system allows motion.',
52
53
  thinkAutoExpand: 'Auto-expand thinking',
54
+ logarithmicFade: 'Logarithmic fade',
55
+ logarithmicFadeHint: 'Fade new answer and expanded thinking text into view. Follows the motion preference.',
53
56
  thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
54
57
  debugEnabled: 'Show render diagnostics',
55
58
  debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
@@ -138,6 +141,8 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
138
141
  motionForceReduced: '始终原始',
139
142
  motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
140
143
  thinkAutoExpand: '自动展开思考',
144
+ logarithmicFade: '对数淡入',
145
+ logarithmicFadeHint: '让回答正文和展开的思考文字由淡变实,遵循动效偏好。',
141
146
  thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
142
147
  debugEnabled: '显示渲染调试面板',
143
148
  debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
@@ -26,6 +26,7 @@ export interface SmoothStreamCardState {
26
26
  controlScroll: boolean
27
27
  motionPreference: StreamMotionPreference
28
28
  thinkAutoExpand: boolean
29
+ logarithmicFade: boolean
29
30
  debugEnabled: boolean
30
31
  debugTuning: StreamDebugTuning
31
32
  debugAvailable: boolean
@@ -54,7 +55,7 @@ export class SmoothStreamCardController {
54
55
  private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
55
56
  private loaded: StreamSettingsView | undefined
56
57
  private loadedDebug: StreamDebugSettingsView | undefined
57
- private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
58
+ private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
58
59
  private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
59
60
  private saving = false
60
61
  private failed = false
@@ -92,13 +93,14 @@ export class SmoothStreamCardController {
92
93
  hooks: { smoothStreamCard: this.store },
93
94
  edit: (patch) => {
94
95
  if (this.saving) return
95
- if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
96
+ if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
96
97
  this.stagedBase = {
97
98
  ...this.baseValues(),
98
99
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
99
100
  ...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
100
101
  ...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
101
102
  ...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
103
+ ...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
102
104
  }
103
105
  }
104
106
  if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
@@ -143,12 +145,13 @@ export class SmoothStreamCardController {
143
145
  }
144
146
  }
145
147
 
146
- private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
148
+ private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
147
149
  return this.stagedBase ?? {
148
150
  enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
149
151
  controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
150
152
  motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
151
153
  thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
154
+ logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
152
155
  }
153
156
  }
154
157