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/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +406 -77
- package/lib/client.js.map +1 -1
- package/lib/index.js +203 -1
- package/package.json +1 -1
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +13 -0
- package/src/client/TypewriterAssistantNodeView.tsx +16 -2
- package/src/client/index.ts +2 -0
- package/src/client/locales.ts +5 -0
- package/src/client/smooth-stream-card-controller.ts +6 -3
- package/src/client/smooth-stream-settings-api.ts +5 -3
- package/src/client/teleprompterGlide.ts +8 -18
- package/src/client/useLogarithmicFade.ts +293 -0
- package/src/plugin.ts +16 -4
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +277 -0
- package/src/settings.ts +3 -0
|
@@ -26,10 +26,11 @@ function settingsView(value: unknown): StreamSettingsView {
|
|
|
26
26
|
|| typeof data.controlScroll !== 'boolean'
|
|
27
27
|
|| !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
|
|
28
28
|
|| typeof data.thinkAutoExpand !== 'boolean'
|
|
29
|
+
|| (data.logarithmicFade !== undefined && typeof data.logarithmicFade !== 'boolean')
|
|
29
30
|
|| typeof data.canUpgrade !== 'boolean') {
|
|
30
31
|
throw new Error('dsh-smooth-stream: malformed settings response')
|
|
31
32
|
}
|
|
32
|
-
return data as unknown as StreamSettingsView
|
|
33
|
+
return { ...data, logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function upgradeView(value: unknown): StreamUpgradeView {
|
|
@@ -69,7 +70,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
|
|
|
69
70
|
/** Narrow client contract consumed by the staged settings-card controller. */
|
|
70
71
|
export interface SmoothStreamSettingsApi {
|
|
71
72
|
read(): Promise<StreamSettingsView>
|
|
72
|
-
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
73
|
+
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
73
74
|
readDebug(): Promise<StreamDebugSettingsView>
|
|
74
75
|
writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
|
|
75
76
|
upgrade(): Promise<StreamUpgradeView>
|
|
@@ -81,7 +82,7 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
|
|
|
81
82
|
async read(): Promise<StreamSettingsView> {
|
|
82
83
|
return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
|
|
83
84
|
},
|
|
84
|
-
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
85
|
+
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
85
86
|
return settingsView(accepted(await connection.rpc.call(
|
|
86
87
|
STREAM_SETTINGS_RPC_CHANNEL,
|
|
87
88
|
STREAM_SETTINGS_RPC.write,
|
|
@@ -90,6 +91,7 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
|
|
|
90
91
|
controlScroll: settings.controlScroll,
|
|
91
92
|
motionPreference: settings.motionPreference,
|
|
92
93
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
94
|
+
logarithmicFade: settings.logarithmicFade,
|
|
93
95
|
...(settings.debugEnabled === undefined || settings.debugTuning === undefined
|
|
94
96
|
? {}
|
|
95
97
|
: { debugEnabled: settings.debugEnabled, debugTuning: settings.debugTuning }),
|
|
@@ -780,8 +780,9 @@ function measureReadingAnchor(port: HTMLElement): { anchor: HTMLElement; index:
|
|
|
780
780
|
return null
|
|
781
781
|
}
|
|
782
782
|
// Host-caused motion only: the engine's own shift glide and floor changes
|
|
783
|
-
// cancel out (renderedΔ = topΔ − shiftΔ +
|
|
784
|
-
const
|
|
783
|
+
// cancel out (renderedΔ = topΔ + scrollΔ − shiftΔ + padΔ).
|
|
784
|
+
const scrollDelta = scrollTop - stored.scrollTop
|
|
785
|
+
const delta = (top - stored.top) + scrollDelta - (shift - stored.shift) + (pad - stored.pad)
|
|
785
786
|
if (stored.element === anchor) {
|
|
786
787
|
// Refresh the baseline to the current rendered position on every
|
|
787
788
|
// measurement: between guard passes the viewport legitimately moves
|
|
@@ -2492,25 +2493,14 @@ export function useConversationFollow(
|
|
|
2492
2493
|
// measure the extent against it):
|
|
2493
2494
|
let settleQuietMs = 0
|
|
2494
2495
|
let settleSig = ''
|
|
2495
|
-
|
|
2496
|
-
0,
|
|
2497
|
-
host.scrollHeight - animatedH - runwayOffsetOf(host),
|
|
2498
|
-
)
|
|
2499
|
-
const currentRunway = runwayOffsetOf(host)
|
|
2500
|
-
const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host))
|
|
2501
|
-
if (
|
|
2502
|
-
!activeRef.current
|
|
2503
|
-
&& lagBeforeCompletionPaint <= FOLLOW_SLACK_PX
|
|
2504
|
-
&& currentRunway <= FOLLOW_SETTLE_EPSILON_PX
|
|
2505
|
-
&& currentShift <= FOLLOW_SETTLE_EPSILON_PX
|
|
2506
|
-
// A live completion pad must retire through the settle's glide first;
|
|
2507
|
-
// fast-finishing here would freeze it in place as visible bottom gap.
|
|
2508
|
-
&& flowPadOf(host) <= FOLLOW_SETTLE_EPSILON_PX
|
|
2509
|
-
) {
|
|
2496
|
+
if (!activeRef.current) {
|
|
2510
2497
|
followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
|
|
2511
2498
|
followTrace('fast-gate', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
|
|
2512
|
-
|
|
2499
|
+
restoreRunway(host)
|
|
2500
|
+
setFlowPad(host, 0)
|
|
2501
|
+
finishAtNaturalFloor(host, !startedAsEntrance, true)
|
|
2513
2502
|
followLeaders.delete(host)
|
|
2503
|
+
followCompletionSettle.delete(host)
|
|
2514
2504
|
releaseRevealScale()
|
|
2515
2505
|
debugRuntime.reportFollow(host, null)
|
|
2516
2506
|
return
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import css from './LogarithmicFade.module.css'
|
|
3
|
+
|
|
4
|
+
export const FADE_DURATION_MS = 240
|
|
5
|
+
export const FADE_TAIL_SIZE = 24
|
|
6
|
+
export const FADE_MAX_TAIL_SIZE = 160
|
|
7
|
+
export const FADE_MIN_OPACITY = 0
|
|
8
|
+
const FADE_STEPS = 32
|
|
9
|
+
const PREFIX = 'dsh-smooth-stream-log-fade-'
|
|
10
|
+
const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
|
|
11
|
+
// Lightning CSS scopes highlight identifiers as well as class names.
|
|
12
|
+
const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
|
|
13
|
+
const EXCLUDED = 'pre,code,math,.katex,.katex-display,mjx-container,svg,script,style,textarea,input,button,select,[role="button"],[contenteditable],[hidden],[aria-hidden="true"],[aria-live]'
|
|
14
|
+
|
|
15
|
+
export function logarithmicOpacity(progress: number): number {
|
|
16
|
+
const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
|
|
17
|
+
// Reverse the log easing: preserve translucency early, then settle to ink.
|
|
18
|
+
return FADE_MIN_OPACITY + (1 - FADE_MIN_OPACITY) * (1 - Math.log1p(5 * (1 - p)) / Math.log(6))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fadeTailSize(speedCps: number): number {
|
|
22
|
+
const speed = Number.isFinite(speedCps) ? Math.max(0, speedCps) : 0
|
|
23
|
+
return Math.min(FADE_MAX_TAIL_SIZE, Math.max(FADE_TAIL_SIZE, Math.ceil(speed * FADE_DURATION_MS / 1000)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FadeCharacter {
|
|
27
|
+
start: number
|
|
28
|
+
end: number
|
|
29
|
+
born: number
|
|
30
|
+
range: Range
|
|
31
|
+
bucket: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Scheduler {
|
|
35
|
+
highlights: Highlight[]
|
|
36
|
+
clients: Set<LogarithmicFadeController>
|
|
37
|
+
pending: Set<LogarithmicFadeController>
|
|
38
|
+
frame: number
|
|
39
|
+
registry: HighlightRegistry
|
|
40
|
+
window: Window
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const schedulers = new WeakMap<Document, Scheduler>()
|
|
44
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
45
|
+
|
|
46
|
+
function schedule(scheduler: Scheduler): void {
|
|
47
|
+
if (scheduler.frame !== 0 || scheduler.pending.size === 0) return
|
|
48
|
+
scheduler.frame = scheduler.window.requestAnimationFrame((now) => {
|
|
49
|
+
scheduler.frame = 0
|
|
50
|
+
for (const client of scheduler.pending) {
|
|
51
|
+
if (!client.paint(now)) scheduler.pending.delete(client)
|
|
52
|
+
}
|
|
53
|
+
schedule(scheduler)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schedulerFor(root: HTMLElement): Scheduler | null {
|
|
58
|
+
const doc = root.ownerDocument
|
|
59
|
+
const win = doc.defaultView
|
|
60
|
+
if (win === null) return null
|
|
61
|
+
// Use the root's realm, including when a renderer lives in another document.
|
|
62
|
+
const realm = win as Window & typeof globalThis
|
|
63
|
+
if (typeof realm.Highlight !== 'function' || !realm.CSS?.highlights
|
|
64
|
+
|| !realm.CSS.supports('color', 'color-mix(in srgb, currentColor 15%, transparent)')) return null
|
|
65
|
+
let scheduler = schedulers.get(doc)
|
|
66
|
+
if (scheduler === undefined) {
|
|
67
|
+
const highlights = Array.from({ length: FADE_STEPS }, () => new realm.Highlight())
|
|
68
|
+
for (const [index, highlight] of highlights.entries()) realm.CSS.highlights.set(highlightName(index), highlight)
|
|
69
|
+
scheduler = {
|
|
70
|
+
highlights,
|
|
71
|
+
clients: new Set(),
|
|
72
|
+
pending: new Set(),
|
|
73
|
+
frame: 0,
|
|
74
|
+
registry: realm.CSS.highlights,
|
|
75
|
+
window: win,
|
|
76
|
+
}
|
|
77
|
+
schedulers.set(doc, scheduler)
|
|
78
|
+
}
|
|
79
|
+
return scheduler
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Owns ranges only: React retains ownership of every element and Text node. */
|
|
83
|
+
export class LogarithmicFadeController {
|
|
84
|
+
private previous = ''
|
|
85
|
+
private characters: FadeCharacter[] = []
|
|
86
|
+
private colors = new Map<HTMLElement, { value: string, priority: string }>()
|
|
87
|
+
private enabled = false
|
|
88
|
+
private active = false
|
|
89
|
+
private speedCps = 100
|
|
90
|
+
private pausedAt: number | null = null
|
|
91
|
+
private disposed = false
|
|
92
|
+
private readonly observer: MutationObserver
|
|
93
|
+
|
|
94
|
+
private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
|
|
95
|
+
scheduler.clients.add(this)
|
|
96
|
+
const win = root.ownerDocument.defaultView as Window & typeof globalThis
|
|
97
|
+
this.observer = new win.MutationObserver(() => { this.reconcile() })
|
|
98
|
+
this.observer.observe(root, { subtree: true, childList: true, characterData: true })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static create(root: HTMLElement): LogarithmicFadeController | null {
|
|
102
|
+
const scheduler = schedulerFor(root)
|
|
103
|
+
return scheduler === null ? null : new LogarithmicFadeController(root, scheduler)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
update(enabled: boolean, active: boolean, speedCps = 100, paused = false): void {
|
|
107
|
+
const now = this.scheduler.window.performance.now()
|
|
108
|
+
if (paused && this.pausedAt === null) this.pausedAt = now
|
|
109
|
+
if (!paused && this.pausedAt !== null) {
|
|
110
|
+
const pauseDuration = now - this.pausedAt
|
|
111
|
+
for (const character of this.characters) character.born += pauseDuration
|
|
112
|
+
this.pausedAt = null
|
|
113
|
+
}
|
|
114
|
+
this.enabled = enabled
|
|
115
|
+
this.active = active
|
|
116
|
+
this.speedCps = speedCps
|
|
117
|
+
this.reconcile()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private clearRanges(): void {
|
|
121
|
+
for (const character of this.characters) {
|
|
122
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
123
|
+
}
|
|
124
|
+
this.characters = []
|
|
125
|
+
this.restoreColors()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private restoreColors(): void {
|
|
129
|
+
for (const [element, original] of this.colors) {
|
|
130
|
+
if (original.value === '') element.style.removeProperty(COLOR_PROPERTY)
|
|
131
|
+
else element.style.setProperty(COLOR_PROPERTY, original.value, original.priority)
|
|
132
|
+
}
|
|
133
|
+
this.colors.clear()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private preserveColor(element: HTMLElement): void {
|
|
137
|
+
if (this.colors.has(element)) return
|
|
138
|
+
const color = this.scheduler.window.getComputedStyle(element).color
|
|
139
|
+
this.colors.set(element, {
|
|
140
|
+
value: element.style.getPropertyValue(COLOR_PROPERTY),
|
|
141
|
+
priority: element.style.getPropertyPriority(COLOR_PROPERTY),
|
|
142
|
+
})
|
|
143
|
+
// An explicit source color prevents highlight inheritance from multiplying
|
|
144
|
+
// alpha through nested Markdown elements (root → paragraph → strong).
|
|
145
|
+
element.style.setProperty(COLOR_PROPERTY, color)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private reconcile(): void {
|
|
149
|
+
if (this.disposed) return
|
|
150
|
+
const text = this.root.textContent ?? ''
|
|
151
|
+
const previous = this.previous
|
|
152
|
+
this.previous = text
|
|
153
|
+
const old = this.characters
|
|
154
|
+
this.clearRanges()
|
|
155
|
+
if (!this.enabled) {
|
|
156
|
+
this.root.classList.remove(css.scope!)
|
|
157
|
+
this.scheduler.pending.delete(this)
|
|
158
|
+
this.stopIfIdle()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
this.root.classList.add(css.scope!)
|
|
162
|
+
const now = this.pausedAt ?? this.scheduler.window.performance.now()
|
|
163
|
+
// A parser rewrite must not replay already readable content. Retain only
|
|
164
|
+
// the unchanged prefix; future appends resume the effect normally.
|
|
165
|
+
let prefix = 0
|
|
166
|
+
while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
|
|
167
|
+
const appended = text.startsWith(previous)
|
|
168
|
+
const nodes: { node: Text, start: number, end: number, eligible: boolean }[] = []
|
|
169
|
+
const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
|
|
170
|
+
let offset = 0
|
|
171
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
172
|
+
const end = offset + (node.textContent?.length ?? 0)
|
|
173
|
+
nodes.push({ node: node as Text, start: offset, end, eligible: node.parentElement?.closest(EXCLUDED) === null })
|
|
174
|
+
offset = end
|
|
175
|
+
}
|
|
176
|
+
// containing() walks backwards by grapheme without segmenting the entire
|
|
177
|
+
// answer into an array. Offsets still refer to the original DOM text.
|
|
178
|
+
const segments = segmenter.segment(text)
|
|
179
|
+
// Keep existing characters alive when the engine resets its speed during
|
|
180
|
+
// completion. A shrinking window must not abruptly darken the old tail.
|
|
181
|
+
const tailSize = fadeTailSize(this.speedCps)
|
|
182
|
+
const oldestLiveStart = old.reduce((start, character) => (
|
|
183
|
+
now - character.born < FADE_DURATION_MS && character.end <= prefix
|
|
184
|
+
? Math.min(start, character.start)
|
|
185
|
+
: start
|
|
186
|
+
), Infinity)
|
|
187
|
+
let end = text.length
|
|
188
|
+
for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
|
|
189
|
+
const segment = segments.containing(end - 1)
|
|
190
|
+
if (segment === undefined) break
|
|
191
|
+
const start = segment.index
|
|
192
|
+
const parts = nodes.filter(node => node.end > start && node.start < end)
|
|
193
|
+
const retained = old.find(character => character.start === start && character.end === end && end <= prefix)
|
|
194
|
+
const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
|
|
195
|
+
if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== ''
|
|
196
|
+
&& parts.length > 0 && parts.every(part => part.eligible)) {
|
|
197
|
+
const first = parts[0]!
|
|
198
|
+
const last = parts[parts.length - 1]!
|
|
199
|
+
const range = this.root.ownerDocument.createRange()
|
|
200
|
+
range.setStart(first.node, start - first.start)
|
|
201
|
+
range.setEnd(last.node, end - last.start)
|
|
202
|
+
for (const part of parts) this.preserveColor(part.node.parentElement!)
|
|
203
|
+
this.characters.push({ start, end, born, range, bucket: -1 })
|
|
204
|
+
}
|
|
205
|
+
end = start
|
|
206
|
+
}
|
|
207
|
+
if (this.paint(now)) {
|
|
208
|
+
if (this.pausedAt === null) {
|
|
209
|
+
this.scheduler.pending.add(this)
|
|
210
|
+
schedule(this.scheduler)
|
|
211
|
+
} else {
|
|
212
|
+
this.scheduler.pending.delete(this)
|
|
213
|
+
this.stopIfIdle()
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
this.scheduler.pending.delete(this)
|
|
217
|
+
this.stopIfIdle()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
paint(now: number): boolean {
|
|
222
|
+
this.characters = this.characters.filter((character) => {
|
|
223
|
+
const progress = (now - character.born) / FADE_DURATION_MS
|
|
224
|
+
if (progress >= 1 || !this.root.isConnected || !this.root.contains(character.range.startContainer)) {
|
|
225
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
const bucket = Math.min(FADE_STEPS - 1, Math.round((logarithmicOpacity(progress) - FADE_MIN_OPACITY) / (1 - FADE_MIN_OPACITY) * (FADE_STEPS - 1)))
|
|
229
|
+
if (bucket !== character.bucket) {
|
|
230
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
231
|
+
this.scheduler.highlights[bucket]!.add(character.range)
|
|
232
|
+
character.bucket = bucket
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
})
|
|
236
|
+
if (this.characters.length === 0) this.restoreColors()
|
|
237
|
+
return this.characters.length > 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private stopIfIdle(): void {
|
|
241
|
+
if (this.scheduler.pending.size !== 0) return
|
|
242
|
+
this.scheduler.window.cancelAnimationFrame(this.scheduler.frame)
|
|
243
|
+
this.scheduler.frame = 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
dispose(): void {
|
|
247
|
+
if (this.disposed) return
|
|
248
|
+
this.disposed = true
|
|
249
|
+
this.observer.disconnect()
|
|
250
|
+
this.clearRanges()
|
|
251
|
+
this.root.classList.remove(css.scope!)
|
|
252
|
+
this.scheduler.pending.delete(this)
|
|
253
|
+
this.scheduler.clients.delete(this)
|
|
254
|
+
this.stopIfIdle()
|
|
255
|
+
if (this.scheduler.clients.size === 0) {
|
|
256
|
+
for (const [index, highlight] of this.scheduler.highlights.entries()) {
|
|
257
|
+
const name = highlightName(index)
|
|
258
|
+
if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
|
|
259
|
+
}
|
|
260
|
+
schedulers.delete(this.root.ownerDocument)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** active admits new characters; enabled=false also cancels completion linger. */
|
|
266
|
+
export function useLogarithmicFade(
|
|
267
|
+
rootRef: RefObject<HTMLElement | null>,
|
|
268
|
+
enabled: boolean,
|
|
269
|
+
active: boolean,
|
|
270
|
+
speedCpsRef?: { current: number },
|
|
271
|
+
paused = false,
|
|
272
|
+
): void {
|
|
273
|
+
const controller = useRef<LogarithmicFadeController | null>(null)
|
|
274
|
+
const committed = useRef(false)
|
|
275
|
+
useLayoutEffect(() => {
|
|
276
|
+
return () => {
|
|
277
|
+
controller.current?.dispose()
|
|
278
|
+
controller.current = null
|
|
279
|
+
}
|
|
280
|
+
}, [rootRef])
|
|
281
|
+
// Deliberately commit-driven, including Markdown updates with unchanged source.
|
|
282
|
+
useLayoutEffect(() => {
|
|
283
|
+
const root = rootRef.current
|
|
284
|
+
// Settled history allocates neither observers nor highlight buckets.
|
|
285
|
+
if (controller.current === null && root !== null && enabled && active) {
|
|
286
|
+
controller.current = LogarithmicFadeController.create(root)
|
|
287
|
+
// Enabling midway through a message must not replay readable text.
|
|
288
|
+
if (committed.current) controller.current?.update(false, false)
|
|
289
|
+
}
|
|
290
|
+
controller.current?.update(enabled, active, speedCpsRef?.current, paused)
|
|
291
|
+
committed.current = true
|
|
292
|
+
})
|
|
293
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
|
|
|
9
9
|
import { injectStreamConfig } from './boot-config.ts'
|
|
10
10
|
import { STREAM_PACKAGE_NAME, STREAM_PACKAGE_VERSION } from './package-meta.ts'
|
|
11
11
|
import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
|
|
12
|
+
import { registerSettingsChannel } from './settings-channel.ts'
|
|
12
13
|
import {
|
|
13
14
|
STREAM_SETTINGS_RPC,
|
|
14
15
|
STREAM_SETTINGS_RPC_CHANNEL,
|
|
@@ -62,6 +63,7 @@ export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
|
|
|
62
63
|
Schema.const('force-reduced'),
|
|
63
64
|
] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
64
65
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
66
|
+
logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
|
|
65
67
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
66
68
|
debugTuning: Schema.object({
|
|
67
69
|
revealScale: Schema.number().min(0.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -125,6 +127,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
125
127
|
controlScroll: settings.controlScroll,
|
|
126
128
|
motionPreference: settings.motionPreference,
|
|
127
129
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
130
|
+
logarithmicFade: settings.logarithmicFade,
|
|
128
131
|
canUpgrade: installation.kind === 'npm',
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -193,6 +196,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
193
196
|
controlScroll: boolean
|
|
194
197
|
motionPreference?: unknown
|
|
195
198
|
thinkAutoExpand: boolean
|
|
199
|
+
logarithmicFade?: unknown
|
|
196
200
|
debugEnabled?: unknown
|
|
197
201
|
debugTuning?: unknown
|
|
198
202
|
}
|
|
@@ -211,6 +215,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
211
215
|
},
|
|
212
216
|
}
|
|
213
217
|
}
|
|
218
|
+
if (next.logarithmicFade !== undefined && typeof next.logarithmicFade !== 'boolean') {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
error: {
|
|
222
|
+
code: 'settings-rejected',
|
|
223
|
+
message: 'logarithmicFade must be a boolean',
|
|
224
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
214
228
|
const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
|
|
215
229
|
if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
|
|
216
230
|
return {
|
|
@@ -227,6 +241,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
227
241
|
controlScroll: next.controlScroll,
|
|
228
242
|
...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
|
|
229
243
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
244
|
+
...(next.logarithmicFade === undefined ? {} : { logarithmicFade: next.logarithmicFade }),
|
|
230
245
|
...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
|
|
231
246
|
})
|
|
232
247
|
} catch {
|
|
@@ -308,10 +323,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
308
323
|
}
|
|
309
324
|
return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
|
|
310
325
|
}
|
|
311
|
-
connectionCtx
|
|
312
|
-
() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
|
|
313
|
-
'dsh-smooth-stream: settings RPC',
|
|
314
|
-
)
|
|
326
|
+
registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle)
|
|
315
327
|
})
|
|
316
328
|
})
|
|
317
329
|
}
|
package/src/settings-api.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface StreamSettingsView {
|
|
|
31
31
|
motionPreference: import('./settings.ts').StreamMotionPreference
|
|
32
32
|
/** Current resolved preference. */
|
|
33
33
|
thinkAutoExpand: boolean
|
|
34
|
+
/** Whether new answer/thinking text fades into its original color. */
|
|
35
|
+
logarithmicFade: boolean
|
|
34
36
|
/** Whether a fixed npm update command is safe to offer. */
|
|
35
37
|
canUpgrade: boolean
|
|
36
38
|
}
|