dsh-smooth-stream 0.1.0 → 0.3.2

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.
@@ -0,0 +1,65 @@
1
+ /** Browser adapter for the Host-owned smooth-stream settings RPC. */
2
+
3
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
4
+ import {
5
+ STREAM_SETTINGS_RPC,
6
+ STREAM_SETTINGS_RPC_CHANNEL,
7
+ type StreamSettingsView,
8
+ type StreamUpgradeView,
9
+ } from '../settings-api.ts'
10
+
11
+ function record(value: unknown): Record<string, unknown> | undefined {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
13
+ ? value as Record<string, unknown>
14
+ : undefined
15
+ }
16
+
17
+ function settingsView(value: unknown): StreamSettingsView {
18
+ const data = record(value)
19
+ if (data === undefined
20
+ || typeof data.version !== 'string'
21
+ || !['npm', 'development', 'unmanaged'].includes(data.installation as string)
22
+ || typeof data.writable !== 'boolean'
23
+ || typeof data.thinkAutoExpand !== 'boolean'
24
+ || typeof data.canUpgrade !== 'boolean') {
25
+ throw new Error('dsh-smooth-stream: malformed settings response')
26
+ }
27
+ return data as unknown as StreamSettingsView
28
+ }
29
+
30
+ function upgradeView(value: unknown): StreamUpgradeView {
31
+ const data = record(value)
32
+ if (data?.restartRequired !== true) throw new Error('dsh-smooth-stream: malformed update response')
33
+ return { restartRequired: true }
34
+ }
35
+
36
+ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>): unknown {
37
+ if (!result.ok) throw new Error(result.error.message)
38
+ return result.value
39
+ }
40
+
41
+ /** Narrow client contract consumed by the staged settings-card controller. */
42
+ export interface SmoothStreamSettingsApi {
43
+ read(): Promise<StreamSettingsView>
44
+ write(thinkAutoExpand: boolean): Promise<StreamSettingsView>
45
+ upgrade(): Promise<StreamUpgradeView>
46
+ }
47
+
48
+ /** Build the typed facade over the generic Connection RPC service. */
49
+ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): SmoothStreamSettingsApi {
50
+ return {
51
+ async read(): Promise<StreamSettingsView> {
52
+ return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
53
+ },
54
+ async write(thinkAutoExpand: boolean): Promise<StreamSettingsView> {
55
+ return settingsView(accepted(await connection.rpc.call(
56
+ STREAM_SETTINGS_RPC_CHANNEL,
57
+ STREAM_SETTINGS_RPC.write,
58
+ { thinkAutoExpand },
59
+ )))
60
+ },
61
+ async upgrade(): Promise<StreamUpgradeView> {
62
+ return upgradeView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.upgrade, {})))
63
+ },
64
+ }
65
+ }
@@ -14,17 +14,15 @@
14
14
  * - while the port has scroll room, writes the interpolated lag as
15
15
  * `translate3d` on `[data-chat-transcript]` (message rows only), so the
16
16
  * turn-status chrome stays pinned at the floor;
17
- * - before the port has scroll room (content shorter than the viewport), the
18
- * same lag is a content-height lag written as a negative translate on
19
- * `[data-chat-turn-status]`, so the status label descends smoothly with
20
- * each wrap instead of hopping a line at a time.
17
+ * - clips only the part of the translated surface that crosses the
18
+ * turn-status line, preserving the normal gap above that line;
19
+ * - leaves `[data-chat-turn-status]` in normal flow at every height, so its
20
+ * layout gap cannot be consumed by an interpolated transform.
21
21
  *
22
- * Handing the port back to the reader (unpin, or the owner going inactive)
23
- * first writes the effective visual top (`engine - lag`) into `scrollTop`
24
- * before clearing the transform, so the frame stays continuous. That write
25
- * also lands in ChatView's observed-top ledger as reader input beyond
26
- * `FOLLOW_THRESHOLD`, releasing its native snap-follow instead of fighting
27
- * the gesture for the port.
22
+ * A real reader unpin first writes the effective visual top (`engine - lag`)
23
+ * into `scrollTop` before clearing the transform, so the frame stays
24
+ * continuous. Lifecycle completion is different: without a reader gesture,
25
+ * the last owner settles at the floor and preserves bottom-follow.
28
26
  *
29
27
  * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
30
28
  * floor; a `scrollTop` delta from our own write must not release the pin.
@@ -136,35 +134,54 @@ function hasShiftSurface(port: HTMLElement): boolean {
136
134
  return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
137
135
  }
138
136
 
139
- function setShift(element: HTMLElement, px: number, negative = false): void {
140
- const value = negative ? -px : px
141
- if (Math.abs(value) > 0.01) {
142
- element.style.transform = `translate3d(0, ${value}px, 0)`
137
+ /** Cached natural gap from a shifted surface's bottom to the status line. */
138
+ const statusGapCache = new WeakMap<HTMLElement, { chrome: HTMLElement; px: number }>()
139
+
140
+ function currentShiftOf(element: HTMLElement): number {
141
+ return Number(/translate3d\(0, ([\d.]+)px, 0\)/.exec(element.style.transform)?.[1] ?? 0)
142
+ }
143
+
144
+ function statusGapBelow(surface: HTMLElement, chrome: HTMLElement): number {
145
+ const cached = statusGapCache.get(surface)
146
+ if (cached?.chrome === chrome) return cached.px
147
+ // getBoundingClientRect includes our current transform. Remove it to recover
148
+ // the layout bottom, then keep the existing gap available to the reveal.
149
+ const naturalBottom = surface.getBoundingClientRect().bottom - currentShiftOf(surface)
150
+ const px = Math.max(0, chrome.getBoundingClientRect().top - naturalBottom)
151
+ statusGapCache.set(surface, { chrome, px })
152
+ return px
153
+ }
154
+
155
+ function setShift(element: HTMLElement, px: number, paintAllowance?: number): void {
156
+ if (Math.abs(px) > 0.01) {
157
+ element.style.transform = `translate3d(0, ${px}px, 0)`
143
158
  element.style.willChange = 'transform'
159
+ const bottomClip = paintAllowance === undefined ? 0 : Math.max(0, px - paintAllowance)
160
+ element.style.clipPath = bottomClip > 0.01 ? `inset(0 0 ${bottomClip}px 0)` : ''
144
161
  } else {
145
162
  element.style.transform = ''
146
163
  element.style.willChange = ''
164
+ element.style.clipPath = ''
147
165
  }
148
166
  }
149
167
 
150
168
  /**
151
- * The running-turn label. Before the port scrolls (content shorter than the
152
- * viewport) the content-height lag has no `scrollTop` room to ride, so it is
153
- * expressed as a negative translate on this sibling: the label descends
154
- * smoothly with each wrap instead of hopping down a line at a time.
169
+ * The running-turn label. It stays in normal flow so the host column's gap
170
+ * always separates it from the transcript.
155
171
  */
156
- function chromeShiftOf(port: HTMLElement): HTMLElement | null {
157
- return port.querySelector<HTMLElement>('[data-chat-turn-status]')
172
+ function statusChromeOf(port: HTMLElement): HTMLElement | null {
173
+ return port.querySelector<HTMLElement>(
174
+ '[data-chat-turn-status], [data-chat-flow] > [role="status"]',
175
+ )
158
176
  }
159
177
 
160
178
  /**
161
- * Render the single smoothed extent `animatedH`. Before the port scrolls
162
- * (content shorter than the viewport) `animatedH` is clamped to the content
163
- * height and the content-height lag rides a negative translate on the
164
- * turn-status label; once the port has scroll room the engine is pinned at
165
- * the floor and the lag rides `[data-chat-transcript]` (or each message row
166
- * when the host has no transcript box) so the chrome stays put. Without any
167
- * shift surface the engine itself carries the interpolated top.
179
+ * Render the single smoothed extent `animatedH`. Once the port has scroll
180
+ * room the engine is pinned at the floor and the lag rides
181
+ * `[data-chat-transcript]` (or each message row when the host has no
182
+ * transcript box). Before that, message rows and status chrome remain in
183
+ * normal flow. Without any shift surface the engine carries the interpolated
184
+ * top itself.
168
185
  */
169
186
  function applyVisual(port: HTMLElement, animatedH: number): void {
170
187
  const contentHeight = Math.max(0, port.scrollHeight)
@@ -173,19 +190,19 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
173
190
  const lag = contentHeight - extent
174
191
  port.style.overflowAnchor = 'none'
175
192
  port.style.scrollBehavior = 'auto'
193
+ const chrome = statusChromeOf(port)
194
+ if (chrome !== null) setShift(chrome, 0)
176
195
  const shiftRoot = shiftRootOf(port)
177
196
  if (shiftRoot !== null) {
178
197
  if (floor <= 0) {
179
198
  port.scrollTop = 0
180
199
  port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
181
- const chrome = chromeShiftOf(port)
182
- if (chrome !== null) setShift(chrome, lag, true)
183
200
  setShift(shiftRoot, 0)
184
201
  return
185
202
  }
186
203
  port.scrollTop = floor
187
204
  port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
188
- setShift(shiftRoot, lag)
205
+ setShift(shiftRoot, lag, chrome === null ? undefined : statusGapBelow(shiftRoot, chrome))
189
206
  return
190
207
  }
191
208
  const rows = shiftRowsOf(port)
@@ -193,14 +210,16 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
193
210
  if (floor <= 0) {
194
211
  port.scrollTop = 0
195
212
  port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
196
- const chrome = chromeShiftOf(port)
197
- if (chrome !== null) setShift(chrome, lag, true)
198
213
  for (const row of rows) setShift(row, 0)
199
214
  return
200
215
  }
201
216
  port.scrollTop = floor
202
217
  port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
203
- for (const row of rows) setShift(row, lag)
218
+ const last = rows.length - 1
219
+ for (const [index, row] of rows.entries()) {
220
+ const allowance = index === last && chrome !== null ? statusGapBelow(row, chrome) : undefined
221
+ setShift(row, lag, allowance)
222
+ }
204
223
  return
205
224
  }
206
225
  port.scrollTop = Math.min(floor, Math.max(0, extent))
@@ -213,21 +232,32 @@ function clearVisual(port: HTMLElement): void {
213
232
  port.style.scrollBehavior = ''
214
233
  const shiftRoot = shiftRootOf(port)
215
234
  if (shiftRoot !== null) {
235
+ statusGapCache.delete(shiftRoot)
216
236
  shiftRoot.style.transform = ''
217
237
  shiftRoot.style.willChange = ''
238
+ shiftRoot.style.clipPath = ''
218
239
  } else {
219
240
  for (const row of shiftRowsOf(port)) {
241
+ statusGapCache.delete(row)
220
242
  row.style.transform = ''
221
243
  row.style.willChange = ''
244
+ row.style.clipPath = ''
222
245
  }
223
246
  }
224
- const chrome = chromeShiftOf(port)
247
+ const chrome = statusChromeOf(port)
225
248
  if (chrome !== null) {
226
249
  chrome.style.transform = ''
227
250
  chrome.style.willChange = ''
251
+ chrome.style.clipPath = ''
228
252
  }
229
253
  }
230
254
 
255
+ function settleAtFloor(port: HTMLElement): void {
256
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight)
257
+ port.scrollTop = floor
258
+ port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
259
+ }
260
+
231
261
  /** Live follow hosts per conversation port so one unmount does not clear another. */
232
262
  const followOwners = new WeakMap<HTMLElement, number>()
233
263
 
@@ -412,15 +442,15 @@ export function useConversationFollow(
412
442
  const root = rootRef.current
413
443
  const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
414
444
  if (host === null) return
415
- if (following) {
416
- // Same handback as an unpin: land on the effective visual top rather
417
- // than the floor, so the owner change (stream close, unmount) does
418
- // not snap the flow up by the remaining lag.
419
- handBackVisual(host)
420
- }
421
445
  const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
422
446
  holding = null
423
- if (remaining === 0) clearVisual(host)
447
+ if (remaining === 0) {
448
+ if (following) {
449
+ if (interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) handBackVisual(host)
450
+ else settleAtFloor(host)
451
+ }
452
+ clearVisual(host)
453
+ }
424
454
  }
425
455
  }, [active, rootRef, speedCpsRef])
426
456
  }
@@ -0,0 +1,39 @@
1
+ /** Host-only package metadata. The package manifest remains the source of truth. */
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ interface PackageManifest {
8
+ name?: unknown
9
+ version?: unknown
10
+ }
11
+
12
+ function packageManifestPath(): string {
13
+ // Production loads this Node entry from its installed file. Vite's test
14
+ // transform instead supplies an http URL, where the project manifest is the
15
+ // closest equivalent source of truth.
16
+ try {
17
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
18
+ } catch {
19
+ // Fall through to the project root when a browser-oriented test transform
20
+ // has replaced import.meta.url with a non-file URL.
21
+ }
22
+ return join(process.cwd(), 'package.json')
23
+ }
24
+
25
+ const manifest = JSON.parse(readFileSync(packageManifestPath(), 'utf8')) as PackageManifest
26
+
27
+ function readRequiredString(field: keyof PackageManifest): string {
28
+ const value = manifest[field]
29
+ if (typeof value !== 'string' || value.length === 0) {
30
+ throw new Error(`dsh-smooth-stream: package.json must contain a non-empty ${field}`)
31
+ }
32
+ return value
33
+ }
34
+
35
+ /** npm package name, read from this plugin's manifest. */
36
+ export const STREAM_PACKAGE_NAME = readRequiredString('name')
37
+
38
+ /** Version of the code currently loaded by the Host, read from its manifest. */
39
+ export const STREAM_PACKAGE_VERSION = readRequiredString('version')
package/src/plugin.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from '@deepseek-ai/dsh-host-webserver'
3
+ import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
4
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
3
5
  import Schema from '@deepseek-ai/schemastery'
4
6
  import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
5
7
  import { injectStreamConfig } from './boot-config.ts'
8
+ import { STREAM_PACKAGE_NAME, STREAM_PACKAGE_VERSION } from './package-meta.ts'
9
+ import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
10
+ import { STREAM_SETTINGS_RPC, STREAM_SETTINGS_RPC_CHANNEL, type StreamSettingsView } from './settings-api.ts'
11
+ import { DEFAULT_STREAM_SETTINGS, STREAM_SETTINGS_NS, type StreamSettings } from './settings.ts'
6
12
 
7
13
  /** Display name shown by the Host loader while the plugin is mounted. */
8
14
  export const name = 'dsh-smooth-stream'
@@ -31,6 +37,14 @@ export const Config: Schema<Config> = Schema.object({
31
37
  .default(DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec),
32
38
  })
33
39
 
40
+ /**
41
+ * Schema of the user-owned settings section. The Host keeps it in the durable
42
+ * settings provider while the browser edits it through the plugin RPC below.
43
+ */
44
+ export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
45
+ thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
46
+ })
47
+
34
48
  /**
35
49
  * Host half: log the resolved configuration and bridge it to the browser
36
50
  * half. The web boot graph carries no per-entry config, so the validated
@@ -51,4 +65,91 @@ export function apply(ctx: Context, config: Config): void {
51
65
  'dsh-smooth-stream: boot config bridge',
52
66
  )
53
67
  })
68
+ // The core settings RPC deliberately filters third-party namespaces. Keep
69
+ // the durable provider as the authority, but expose this one schema through
70
+ // the plugin's own loopback-only connection channel instead.
71
+ ctx.inject(['settings'], (settingsCtx) => {
72
+ const scope = settingsCtx.settings.register(
73
+ settingsNamespace(STREAM_SETTINGS_NS),
74
+ StreamSettingsSchema,
75
+ { applies: 'live' },
76
+ )
77
+ settingsCtx.inject(['connection'], (connectionCtx) => {
78
+ let upgrade: Promise<void> | undefined
79
+
80
+ const view = (): StreamSettingsView => {
81
+ const installation = inspectProfileInstallation(connectionCtx.baseUrl, STREAM_PACKAGE_NAME)
82
+ return {
83
+ version: STREAM_PACKAGE_VERSION,
84
+ installation: installation.kind,
85
+ writable: connectionCtx.settings.writable,
86
+ thinkAutoExpand: scope.get().thinkAutoExpand,
87
+ canUpgrade: installation.kind === 'npm',
88
+ }
89
+ }
90
+
91
+ const handle: ConnectionRpcHandler = async (endpoint, payload) => {
92
+ if (endpoint === STREAM_SETTINGS_RPC.read) return { ok: true, value: view() }
93
+ if (endpoint === STREAM_SETTINGS_RPC.write) {
94
+ if (typeof payload !== 'object' || payload === null
95
+ || typeof (payload as { thinkAutoExpand?: unknown }).thinkAutoExpand !== 'boolean') {
96
+ return {
97
+ ok: false,
98
+ error: {
99
+ code: 'settings-rejected',
100
+ message: 'thinkAutoExpand must be a boolean',
101
+ details: { ns: STREAM_SETTINGS_NS },
102
+ },
103
+ }
104
+ }
105
+ if (!connectionCtx.settings.writable) {
106
+ return {
107
+ ok: false,
108
+ error: {
109
+ code: 'settings-rejected',
110
+ message: 'smooth-stream settings are read-only',
111
+ details: { ns: STREAM_SETTINGS_NS },
112
+ },
113
+ }
114
+ }
115
+ try {
116
+ await scope.update({ thinkAutoExpand: (payload as { thinkAutoExpand: boolean }).thinkAutoExpand })
117
+ } catch {
118
+ return {
119
+ ok: false,
120
+ error: {
121
+ code: 'settings-rejected',
122
+ message: 'smooth-stream settings update failed',
123
+ details: { ns: STREAM_SETTINGS_NS },
124
+ },
125
+ }
126
+ }
127
+ return { ok: true, value: view() }
128
+ }
129
+ if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
130
+ const installation = inspectProfileInstallation(connectionCtx.baseUrl, STREAM_PACKAGE_NAME)
131
+ if (installation.kind !== 'npm') {
132
+ return { ok: false, error: { code: 'internal', message: 'smooth-stream is not an npm profile dependency', details: {} } }
133
+ }
134
+ if (upgrade !== undefined) {
135
+ return { ok: false, error: { code: 'internal', message: 'smooth-stream update is already running', details: {} } }
136
+ }
137
+ upgrade = updateNpmProfilePackage(installation.profileDir, STREAM_PACKAGE_NAME)
138
+ try {
139
+ await upgrade
140
+ } catch {
141
+ return { ok: false, error: { code: 'internal', message: 'smooth-stream update failed', details: {} } }
142
+ } finally {
143
+ upgrade = undefined
144
+ }
145
+ return { ok: true, value: { restartRequired: true } }
146
+ }
147
+ return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
148
+ }
149
+ connectionCtx.effect(
150
+ () => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
151
+ 'dsh-smooth-stream: settings RPC',
152
+ )
153
+ })
154
+ })
54
155
  }
@@ -0,0 +1,121 @@
1
+ /** Host-only profile source inspection and fixed npm update runner. */
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { readFileSync } from 'node:fs'
5
+ import { basename, isAbsolute, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /** A profile installation that can be updated through a fixed pnpm invocation. */
9
+ export interface NpmProfileInstallation {
10
+ kind: 'npm'
11
+ profileDir: string
12
+ profileName: string
13
+ }
14
+
15
+ /** A local source is deliberately not eligible for one-click npm updates. */
16
+ export interface DevelopmentProfileInstallation {
17
+ kind: 'development'
18
+ }
19
+
20
+ /** The active context is not a recognized profile dependency. */
21
+ export interface UnmanagedProfileInstallation {
22
+ kind: 'unmanaged'
23
+ }
24
+
25
+ /** Installation source visible to the plugin's Host RPC. */
26
+ export type ProfileInstallation = NpmProfileInstallation | DevelopmentProfileInstallation | UnmanagedProfileInstallation
27
+
28
+ interface ProfileManifest {
29
+ dependencies?: unknown
30
+ dsh?: unknown
31
+ }
32
+
33
+ function record(value: unknown): Record<string, unknown> | undefined {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
35
+ ? value as Record<string, unknown>
36
+ : undefined
37
+ }
38
+
39
+ function profileDirectory(baseUrl: string | undefined): string | undefined {
40
+ if (baseUrl === undefined) return undefined
41
+ try {
42
+ const url = new URL(baseUrl)
43
+ return url.protocol === 'file:' ? fileURLToPath(url) : undefined
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ function hasBundle(manifest: ProfileManifest, packageName: string): boolean {
50
+ const dsh = record(manifest.dsh)
51
+ const profile = record(dsh?.profile)
52
+ const bundles = profile?.bundles
53
+ return Array.isArray(bundles) && bundles.includes(packageName)
54
+ }
55
+
56
+ function isLocalSpecifier(specifier: string): boolean {
57
+ return specifier.startsWith('link:')
58
+ || specifier.startsWith('file:')
59
+ || specifier.startsWith('.')
60
+ || isAbsolute(specifier)
61
+ || /^[A-Za-z]:[\\/]/.test(specifier)
62
+ }
63
+
64
+ function isRegistrySpecifier(specifier: string): boolean {
65
+ // A registry range/tag contains neither a scheme nor a path. Treat every
66
+ // package-manager extension (catalog:, patch:, portal:, workspace:, etc.)
67
+ // as unconfirmed rather than guessing that an update may replace it.
68
+ return specifier.length > 0 && !specifier.includes(':') && !/[\\/]/.test(specifier)
69
+ }
70
+
71
+ function isNpmSpecifier(specifier: string, packageName: string): boolean {
72
+ if (!specifier.startsWith('npm:')) return isRegistrySpecifier(specifier)
73
+ const aliased = specifier.slice('npm:'.length)
74
+ if (aliased === packageName) return true
75
+ const prefix = `${packageName}@`
76
+ return aliased.startsWith(prefix) && isRegistrySpecifier(aliased.slice(prefix.length))
77
+ }
78
+
79
+ /**
80
+ * Read only the profile manifest anchored by the current Cordis config tree.
81
+ * A malformed or unrelated tree never receives an update affordance.
82
+ */
83
+ export function inspectProfileInstallation(
84
+ baseUrl: string | undefined,
85
+ packageName: string,
86
+ ): ProfileInstallation {
87
+ const profileDir = profileDirectory(baseUrl)
88
+ if (profileDir === undefined) return { kind: 'unmanaged' }
89
+ let manifest: ProfileManifest
90
+ try {
91
+ manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ProfileManifest
92
+ } catch {
93
+ return { kind: 'unmanaged' }
94
+ }
95
+ const dependencies = record(manifest.dependencies)
96
+ const specifier = dependencies?.[packageName]
97
+ if (typeof specifier !== 'string' || !hasBundle(manifest, packageName)) {
98
+ return { kind: 'unmanaged' }
99
+ }
100
+ if (isLocalSpecifier(specifier)) return { kind: 'development' }
101
+ if (!isNpmSpecifier(specifier, packageName)) return { kind: 'unmanaged' }
102
+ return { kind: 'npm', profileDir, profileName: basename(profileDir) }
103
+ }
104
+
105
+ /** Run the same fixed package update operation that a profile user would invoke. */
106
+ export function updateNpmProfilePackage(profileDir: string, packageName: string): Promise<void> {
107
+ const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
108
+ return new Promise((resolve, reject) => {
109
+ const child = spawn(command, ['update', packageName], {
110
+ cwd: profileDir,
111
+ stdio: 'ignore',
112
+ shell: false,
113
+ windowsHide: true,
114
+ })
115
+ child.once('error', reject)
116
+ child.once('exit', (code, signal) => {
117
+ if (code === 0) resolve()
118
+ else reject(new Error(`dsh-smooth-stream: pnpm update failed (${signal ?? String(code)})`))
119
+ })
120
+ })
121
+ }
@@ -0,0 +1,33 @@
1
+ /** Shared wire vocabulary for the plugin-owned settings RPC channel. */
2
+
3
+ /** Dedicated, loopback-only RPC channel registered by the Host half. */
4
+ export const STREAM_SETTINGS_RPC_CHANNEL = '/smooth-stream'
5
+
6
+ /** Endpoints accepted by {@link STREAM_SETTINGS_RPC_CHANNEL}. */
7
+ export const STREAM_SETTINGS_RPC = {
8
+ read: 'settings.read',
9
+ write: 'settings.write',
10
+ upgrade: 'plugin.upgrade',
11
+ } as const
12
+
13
+ /** How the active profile supplied this plugin. */
14
+ export type StreamInstallationKind = 'npm' | 'development' | 'unmanaged'
15
+
16
+ /** Redacted, plugin-owned configuration state returned to the browser. */
17
+ export interface StreamSettingsView {
18
+ /** Version of the package currently running in the Host process. */
19
+ version: string
20
+ /** Source class of this package in the active profile. */
21
+ installation: StreamInstallationKind
22
+ /** Whether the Host settings provider permits a write. */
23
+ writable: boolean
24
+ /** Current resolved preference. */
25
+ thinkAutoExpand: boolean
26
+ /** Whether a fixed npm update command is safe to offer. */
27
+ canUpgrade: boolean
28
+ }
29
+
30
+ /** Successful package update acknowledgement; loading the new code needs a restart. */
31
+ export interface StreamUpgradeView {
32
+ restartRequired: true
33
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * User-owned settings for the smooth-stream plugin, exposed to the Host
3
+ * settings service and edited from the Web Settings "plugin configuration"
4
+ * page. This is the runtime-editable complement to {@link StreamConfig}: that
5
+ * contract is composed at load and bridged once through the boot global, while
6
+ * these preferences live in the durable user-settings document and take effect
7
+ * live.
8
+ */
9
+
10
+ /** Settings namespace registered by the Host and served through the plugin RPC. */
11
+ export const STREAM_SETTINGS_NS = 'smooth-stream'
12
+
13
+ /**
14
+ * Preferences a user may set. Deliberately separate from {@link StreamConfig}
15
+ * because the two change at different times: composition-time values go
16
+ * through the boot global, a live UI edit goes through the protected plugin RPC.
17
+ */
18
+ export interface StreamSettings {
19
+ /**
20
+ * Whether a reasoning ("Think") block auto-expands while it is the
21
+ * streaming tail. Off keeps the block collapsed — the user can still open
22
+ * it by hand — and stops the running state from re-owning the disclosure.
23
+ */
24
+ thinkAutoExpand: boolean
25
+ }
26
+
27
+ /** Defaults shared by the Host schema and the client-side fallback. */
28
+ export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
29
+ thinkAutoExpand: true,
30
+ }