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.
@@ -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Δ + floorΔ).
784
- const delta = (top - stored.top) - (shift - stored.shift) + (pad - stored.pad)
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
- const lagBeforeCompletionPaint = Math.max(
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
- finishAtNaturalFloor(host, !startedAsEntrance)
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.effect(
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
  }
@@ -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
  }