dsh-smooth-stream 0.1.0 → 0.3.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The smooth-stream plugin configuration card, rendered inside the Web
3
+ * Settings "plugin configuration" page. One toggle, staged until the user
4
+ * saves — the same shape as the Host-shipped cards, hand-drawn because the
5
+ * Host cards' chrome is not exported for reuse.
6
+ */
7
+
8
+ import { useState } from 'react'
9
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
11
+ import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
12
+ import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
13
+ import css from './SmoothStreamCard.module.css'
14
+
15
+ /** Props the renderer binds for the smooth-stream card. */
16
+ export type SmoothStreamCardProps =
17
+ PropsRuntime<'settings.plugin.item'>
18
+ & PropsLocale<'settings.smoothStream'>
19
+ & InjectFace<SmoothStreamCardFace>
20
+
21
+ /** Render the smooth-stream card independently of the core settings namespace allowlist. */
22
+ export function SmoothStreamCard(props: SmoothStreamCardProps) {
23
+ const { t } = props
24
+ const [open, setOpen] = useState(false)
25
+ const state = props.useSmoothStreamCard(snapshot => snapshot)
26
+ const blocked = !state.dirty || state.saving || state.status !== 'ready'
27
+ const versionLabel = state.version === undefined
28
+ ? null
29
+ : t(state.installation === 'development' ? 'developmentVersion' : 'version')
30
+ .replace('{version}', state.version)
31
+
32
+ return (
33
+ <li className={open ? `${css.card} ${css.cardOpen}` : css.card}>
34
+ <button
35
+ type="button"
36
+ className={css.header}
37
+ aria-expanded={open}
38
+ onClick={() => { setOpen(!open) }}
39
+ >
40
+ <span className={css.headText}>
41
+ <span className={css.name}>{t('title')}</span>
42
+ <span className={css.description}>{t('description')}</span>
43
+ </span>
44
+ {versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
45
+ {state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
46
+ <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
47
+ </button>
48
+ {open
49
+ ? (
50
+ <div className={css.body}>
51
+ {state.status === 'loading' ? <p className={css.readOnly} role="status">{t('loading')}</p> : null}
52
+ {state.status === 'unavailable' ? (
53
+ <div className={css.failure}>
54
+ <p className={css.readOnly} role="status">{t('unavailable')}</p>
55
+ <button type="button" className={css.discard} onClick={props.reload}>{t('retry')}</button>
56
+ </div>
57
+ ) : null}
58
+ {state.status === 'ready' ? (
59
+ <>
60
+ {!state.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
61
+ <label className={css.field}>
62
+ <span className={css.fieldHead}>
63
+ <span className={css.label}>{t('thinkAutoExpand')}</span>
64
+ <input
65
+ type="checkbox"
66
+ className={css.toggle}
67
+ checked={state.thinkAutoExpand}
68
+ disabled={!state.writable}
69
+ onChange={(event) => { props.edit(event.target.checked) }}
70
+ />
71
+ </span>
72
+ <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
73
+ </label>
74
+ <div className={css.updateRow}>
75
+ <span className={css.updateCopy}>
76
+ <span className={css.label}>{t('updates')}</span>
77
+ <span className={css.hint}>
78
+ {state.restartRequired
79
+ ? t('restartRequired')
80
+ : state.installation === 'npm' ? t('updateHint')
81
+ : state.installation === 'development' ? t('developmentBuild') : t('updateUnavailable')}
82
+ </span>
83
+ </span>
84
+ <button
85
+ type="button"
86
+ className={css.update}
87
+ disabled={!state.canUpgrade || state.upgrading || state.restartRequired}
88
+ title={state.canUpgrade ? undefined : t('updateUnavailable')}
89
+ onClick={props.upgrade}
90
+ >
91
+ <span aria-hidden="true"><IconRefreshOutline14 /></span>
92
+ {t(state.upgrading ? 'updating' : 'update')}
93
+ </button>
94
+ </div>
95
+ {state.upgradeFailed ? <p className={css.failed} role="status">{t('updateFailed')}</p> : null}
96
+ <div className={css.footer}>
97
+ {state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
98
+ <button
99
+ type="button"
100
+ className={css.discard}
101
+ disabled={!state.dirty || state.saving}
102
+ onClick={props.discard}
103
+ >
104
+ {t('discard')}
105
+ </button>
106
+ <button
107
+ type="button"
108
+ className={css.save}
109
+ disabled={blocked}
110
+ onClick={props.save}
111
+ >
112
+ {t(state.saving ? 'saving' : 'save')}
113
+ </button>
114
+ </div>
115
+ </>
116
+ ) : null}
117
+ </div>
118
+ )
119
+ : null}
120
+ </li>
121
+ )
122
+ }
@@ -7,6 +7,7 @@ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothS
7
7
  import { useFpsGuard } from './useFpsGuard.ts'
8
8
  import { FollowHost } from './FollowHost.tsx'
9
9
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
10
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
10
11
  import css from './TypewriterAssistantNodeView.module.css'
11
12
 
12
13
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
@@ -125,17 +126,19 @@ function AnimatedReasoning({
125
126
  text,
126
127
  running,
127
128
  preset,
129
+ thinkAutoExpand,
128
130
  shouldHoldBack,
129
131
  t,
130
132
  }: {
131
133
  text: string
132
134
  running: boolean
133
135
  preset: StreamSmoothingPreset
136
+ thinkAutoExpand: boolean
134
137
  shouldHoldBack: () => boolean
135
138
  t: AssistantProps['t']
136
139
  }) {
137
140
  const reduced = usePrefersReducedMotion()
138
- const [expanded, setExpanded] = useState(running)
141
+ const [expanded, setExpanded] = useState(running && thinkAutoExpand)
139
142
  const summaryRef = useRef<HTMLSpanElement>(null)
140
143
  const speedCpsRef = useRef(35)
141
144
  const displayed = useSmoothStreamContent(text, {
@@ -148,8 +151,10 @@ function AnimatedReasoning({
148
151
  const summary = running ? latestLine(shown) : firstLine(text)
149
152
 
150
153
  useEffect(() => {
151
- setExpanded(running)
152
- }, [running])
154
+ // Only the running state owns disclosure while auto-expand is on; with it
155
+ // off, a manual toggle is never wrestled back by the stream.
156
+ if (thinkAutoExpand) setExpanded(running)
157
+ }, [running, thinkAutoExpand])
153
158
 
154
159
  useEffect(() => {
155
160
  const element = summaryRef.current
@@ -200,6 +205,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
200
205
  revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec,
201
206
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
202
207
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
208
+ thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
203
209
  node,
204
210
  useTurnData,
205
211
  openFile,
@@ -212,6 +218,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
212
218
  revealCharsPerSec?: number
213
219
  scrollSpeedPxPerSec?: number
214
220
  maxScrollSpeedPxPerSec?: number
221
+ thinkAutoExpand?: boolean
215
222
  }) {
216
223
  const data = node.data
217
224
  const streaming = data.status === 'running'
@@ -272,6 +279,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
272
279
  text={block.text}
273
280
  running={streaming && index === last}
274
281
  preset={preset}
282
+ thinkAutoExpand={thinkAutoExpand}
275
283
  shouldHoldBack={shouldHoldBack}
276
284
  t={t}
277
285
  />,
@@ -1,12 +1,26 @@
1
- import { createElement, type ComponentType } from 'react'
1
+ import { createElement, useSyncExternalStore, type ComponentType } from 'react'
2
2
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
3
3
  import type {} from '@deepseek-ai/dsh-client-locale/client'
4
+ // Type-only: the connection Context merge and the plugins section's SlotMap
5
+ // entry ('settings.plugin.item').
6
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
4
8
  import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
5
9
  import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
6
10
  import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
11
+ import { SmoothStreamCard } from './SmoothStreamCard.tsx'
12
+ import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
13
+ import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
14
+ import { NS as SETTINGS_NS, en, zh } from './locales.ts'
7
15
  import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
16
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
8
17
 
9
- /** Cordis services required by the browser half. */
18
+ /**
19
+ * Cordis services required by the browser half. Only `slots` is load-bearing
20
+ * for the stream itself; locale and Connection power the configuration card
21
+ * and are wired through `ctx.inject` below so a deployment without them still
22
+ * streams with defaults.
23
+ */
10
24
  export const inject = ['slots']
11
25
 
12
26
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
@@ -85,17 +99,95 @@ function wrapGrowingChatRows(ctx: ClientContext): () => void {
85
99
  }
86
100
  }
87
101
 
102
+ /**
103
+ * A live preference cell read by `useSyncExternalStore`. It starts on the
104
+ * shared default and, once the plugin-owned settings controller is attached,
105
+ * tracks the resolved `thinkAutoExpand` value. A composition (or test) that
106
+ * ships no settings surface keeps the cell on the default.
107
+ */
108
+ class PreferenceCell {
109
+ private readonly listeners = new Set<() => void>()
110
+ private card: SmoothStreamCardController | undefined
111
+ private value = DEFAULT_STREAM_SETTINGS.thinkAutoExpand
112
+
113
+ /** Re-point the cell at the plugin-owned settings controller. */
114
+ attach(card: SmoothStreamCardController): () => void {
115
+ this.card = card
116
+ this.refresh()
117
+ const unsubscribe = card.subscribe(() => { this.refresh() })
118
+ return () => {
119
+ unsubscribe()
120
+ if (this.card !== card) return
121
+ this.card = undefined
122
+ this.refresh()
123
+ }
124
+ }
125
+
126
+ private read(): boolean {
127
+ return this.card?.getSnapshot().thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
128
+ }
129
+
130
+ private refresh(): void {
131
+ const next = this.read()
132
+ if (next === this.value) return
133
+ this.value = next
134
+ for (const listener of this.listeners) listener()
135
+ }
136
+
137
+ readonly getSnapshot = (): boolean => this.value
138
+
139
+ readonly subscribe = (listener: () => void): () => void => {
140
+ this.listeners.add(listener)
141
+ return () => { this.listeners.delete(listener) }
142
+ }
143
+ }
144
+
88
145
  /**
89
146
  * Register the typewriter renderer after the conversation package declares the
90
147
  * keyed Chat node seat. A lower priority shadows the built-in assistant row;
91
148
  * every other growing row is wrapped in place so Tool cards, retries, and
92
149
  * workflow runs share conversation follow. The Host-bridged configuration
93
- * selects the render direction, smoothing preset, and glide speed.
150
+ * selects the render direction, smoothing preset, and glide speed; the
151
+ * plugin-owned settings RPC supplies the live auto-expand preference when the
152
+ * settings surface is composed.
94
153
  * @param ctx - Browser context carrying the shared slot registry.
95
154
  */
96
155
  export function apply(ctx: ClientContext): void {
97
156
  const config = readBootConfig()
157
+ const preference = new PreferenceCell()
158
+
159
+ // The card talks to the plugin-owned loopback RPC, so the core settings
160
+ // namespace allowlist cannot make it disappear. The stream still applies
161
+ // with defaults when the optional Settings UI or Connection is absent.
162
+ ctx.inject(['slots', 'locale', 'connection'], (settingsCtx) => {
163
+ const card = new SmoothStreamCardController(
164
+ // The shared Context augmentation also carries the Host-side Connection
165
+ // shape. This browser entry runs after the client provider installs its
166
+ // handle, so narrow through unknown to its client contract here.
167
+ createSmoothStreamSettingsApi(settingsCtx.get('connection') as unknown as ConnectionHandle),
168
+ )
169
+ const detachPreference = preference.attach(card)
170
+ card.start()
171
+ settingsCtx.effect(() => settingsCtx.locale.register(SETTINGS_NS, { zh, en }), 'dsh-smooth-stream: settings dictionaries')
172
+ settingsCtx.slots.inject('settings.plugin.item', () => settingsCtx.slots.register({
173
+ name: 'settings.plugin.item',
174
+ id: 'smooth-stream',
175
+ order: 30,
176
+ locale: SETTINGS_NS,
177
+ inject: () => card.inject(),
178
+ }, SmoothStreamCard))
179
+ return () => {
180
+ card.stop()
181
+ detachPreference()
182
+ }
183
+ })
184
+
98
185
  const configured = function StreamConfiguredView(props: AssistantProps) {
186
+ const thinkAutoExpand = useSyncExternalStore(
187
+ preference.subscribe,
188
+ preference.getSnapshot,
189
+ preference.getSnapshot,
190
+ )
99
191
  return createElement(TypewriterAssistantNodeView, {
100
192
  ...props,
101
193
  mode: config.mode,
@@ -103,6 +195,7 @@ export function apply(ctx: ClientContext): void {
103
195
  revealCharsPerSec: config.revealCharsPerSec,
104
196
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
105
197
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
198
+ thinkAutoExpand,
106
199
  })
107
200
  }
108
201
  ctx.slots.inject('conversation.chat.node', () => {
@@ -0,0 +1,74 @@
1
+ /** Locale bundles for the smooth-stream plugin configuration card. */
2
+
3
+ /** Dictionary namespace owned by this plugin's settings card. */
4
+ export const NS = 'settings.smoothStream'
5
+
6
+ /** Locale keys the card renders. */
7
+ export type SmoothStreamLocaleKey =
8
+ | 'title' | 'description'
9
+ | 'thinkAutoExpand' | 'thinkAutoExpandHint'
10
+ | 'readOnly' | 'loading' | 'unavailable' | 'retry'
11
+ | 'version' | 'developmentVersion'
12
+ | 'updates' | 'updateHint' | 'developmentBuild' | 'updateUnavailable'
13
+ | 'update' | 'updating' | 'restartRequired' | 'updateFailed'
14
+ | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
15
+
16
+ /** English copy. */
17
+ export const en: Record<SmoothStreamLocaleKey, string> = {
18
+ title: 'Smooth stream',
19
+ description: 'How replies are revealed while they stream.',
20
+ thinkAutoExpand: 'Auto-expand thinking',
21
+ thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
22
+ readOnly: 'This deployment stores settings read-only.',
23
+ loading: 'Loading plugin settings…',
24
+ unavailable: 'Plugin settings are unavailable in this connection.',
25
+ retry: 'Retry',
26
+ version: 'Version {version}',
27
+ developmentVersion: 'Development version {version}',
28
+ updates: 'Updates',
29
+ updateHint: 'Install the newest npm version, then restart Harness.',
30
+ developmentBuild: 'Linked source; updates are managed in the checkout.',
31
+ updateUnavailable: 'Updates are available only for an npm profile installation.',
32
+ update: 'Update',
33
+ updating: 'Updating…',
34
+ restartRequired: 'Updated. Restart Harness to load the new version.',
35
+ updateFailed: 'The package update failed; your current version is unchanged.',
36
+ save: 'Save',
37
+ saving: 'Saving…',
38
+ discard: 'Discard',
39
+ unsaved: 'Unsaved',
40
+ saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
41
+ }
42
+
43
+ /** Simplified Chinese copy. */
44
+ export const zh: Record<SmoothStreamLocaleKey, string> = {
45
+ title: '丝滑流式',
46
+ description: '回复在流式输出时如何逐字展现。',
47
+ thinkAutoExpand: '自动展开思考',
48
+ thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
49
+ readOnly: '本部署的设置为只读。',
50
+ loading: '正在加载插件设置…',
51
+ unavailable: '当前连接无法访问插件设置。',
52
+ retry: '重试',
53
+ version: '版本 {version}',
54
+ developmentVersion: '开发版本 {version}',
55
+ updates: '更新',
56
+ updateHint: '安装最新 npm 版本后重启 Harness。',
57
+ developmentBuild: '当前为本地链接版本,请在源码目录管理更新。',
58
+ updateUnavailable: '只有 profile 使用 npm 包时才能更新。',
59
+ update: '更新',
60
+ updating: '更新中…',
61
+ restartRequired: '已更新;重启 Harness 后加载新版本。',
62
+ updateFailed: '包更新失败,当前版本未改变。',
63
+ save: '保存',
64
+ saving: '保存中…',
65
+ discard: '放弃修改',
66
+ unsaved: '未保存',
67
+ saveFailed: '本部署没有接受这些值,已保留供你修改。',
68
+ }
69
+
70
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
71
+ interface LocaleNamespaceMap {
72
+ 'settings.smoothStream': SmoothStreamLocaleKey
73
+ }
74
+ }
@@ -0,0 +1,162 @@
1
+ /** Staged form state for the plugin-owned smooth-stream settings RPC. */
2
+
3
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
4
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
5
+ import type { StreamInstallationKind, StreamSettingsView } from '../settings-api.ts'
6
+ import type { SmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
7
+
8
+ /** What the smooth-stream card renders. It remains visible while its Host RPC loads. */
9
+ export interface SmoothStreamCardState {
10
+ status: 'loading' | 'ready' | 'unavailable'
11
+ writable: boolean
12
+ dirty: boolean
13
+ saving: boolean
14
+ failed: boolean
15
+ thinkAutoExpand: boolean
16
+ version: string | undefined
17
+ installation: StreamInstallationKind
18
+ canUpgrade: boolean
19
+ upgrading: boolean
20
+ upgradeFailed: boolean
21
+ restartRequired: boolean
22
+ }
23
+
24
+ /** The registration-side face injected into the settings slot renderer. */
25
+ export interface SmoothStreamCardFace {
26
+ hooks: {
27
+ smoothStreamCard: SnapshotStore<SmoothStreamCardState>
28
+ }
29
+ edit: (value: boolean) => void
30
+ save: () => void
31
+ discard: () => void
32
+ reload: () => void
33
+ upgrade: () => void
34
+ }
35
+
36
+ /** Bridges the plugin's protected Host interface onto a staged settings form. */
37
+ export class SmoothStreamCardController {
38
+ private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
39
+ private loaded: StreamSettingsView | undefined
40
+ private staged: boolean | undefined
41
+ private saving = false
42
+ private failed = false
43
+ private upgrading = false
44
+ private upgradeFailed = false
45
+ private restartRequired = false
46
+ private loadGeneration = 0
47
+ private loadStatus: 'loading' | 'ready' | 'unavailable' = 'loading'
48
+
49
+ constructor(private readonly api: SmoothStreamSettingsApi) {}
50
+
51
+ /** Begin the background read after the card has been registered. */
52
+ start(): void {
53
+ void this.load()
54
+ }
55
+
56
+ /** Ignore a late response after the surrounding optional services unload. */
57
+ stop(): void {
58
+ this.loadGeneration += 1
59
+ }
60
+
61
+ /** Current card snapshot, also consumed by the streaming preference cell. */
62
+ getSnapshot(): SmoothStreamCardState {
63
+ return this.store.getSnapshot()
64
+ }
65
+
66
+ /** Subscribe to state changes. */
67
+ subscribe(listener: () => void): () => void {
68
+ return this.store.subscribe(listener)
69
+ }
70
+
71
+ /** Build the face consumed by the settings slot renderer. */
72
+ inject(): SmoothStreamCardFace {
73
+ return {
74
+ hooks: { smoothStreamCard: this.store },
75
+ edit: (value) => {
76
+ this.staged = value
77
+ this.failed = false
78
+ this.publish()
79
+ },
80
+ save: () => { void this.save() },
81
+ discard: () => {
82
+ if (this.staged === undefined && !this.failed) return
83
+ this.staged = undefined
84
+ this.failed = false
85
+ this.publish()
86
+ },
87
+ reload: () => { void this.load() },
88
+ upgrade: () => { void this.upgrade() },
89
+ }
90
+ }
91
+
92
+ private projection(): SmoothStreamCardState {
93
+ return {
94
+ status: this.loadStatus,
95
+ writable: this.loaded?.writable ?? false,
96
+ dirty: this.staged !== undefined,
97
+ saving: this.saving,
98
+ failed: this.failed,
99
+ thinkAutoExpand: this.staged ?? this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
100
+ version: this.loaded?.version,
101
+ installation: this.loaded?.installation ?? 'unmanaged',
102
+ canUpgrade: this.loaded?.canUpgrade ?? false,
103
+ upgrading: this.upgrading,
104
+ upgradeFailed: this.upgradeFailed,
105
+ restartRequired: this.restartRequired,
106
+ }
107
+ }
108
+
109
+ private async load(): Promise<void> {
110
+ const generation = ++this.loadGeneration
111
+ this.loadStatus = 'loading'
112
+ this.loaded = undefined
113
+ this.publish()
114
+ try {
115
+ const view = await this.api.read()
116
+ if (generation !== this.loadGeneration) return
117
+ this.loaded = view
118
+ this.loadStatus = 'ready'
119
+ } catch {
120
+ if (generation !== this.loadGeneration) return
121
+ this.loaded = undefined
122
+ this.loadStatus = 'unavailable'
123
+ }
124
+ this.publish()
125
+ }
126
+
127
+ private async save(): Promise<void> {
128
+ if (this.staged === undefined || this.saving || this.loaded?.writable !== true) return
129
+ const value = this.staged
130
+ this.saving = true
131
+ this.failed = false
132
+ this.publish()
133
+ try {
134
+ this.loaded = await this.api.write(value)
135
+ this.staged = undefined
136
+ } catch {
137
+ this.failed = true
138
+ }
139
+ this.saving = false
140
+ this.publish()
141
+ }
142
+
143
+ private async upgrade(): Promise<void> {
144
+ if (this.loaded?.canUpgrade !== true || this.upgrading) return
145
+ this.upgrading = true
146
+ this.upgradeFailed = false
147
+ this.restartRequired = false
148
+ this.publish()
149
+ try {
150
+ const result = await this.api.upgrade()
151
+ this.restartRequired = result.restartRequired
152
+ } catch {
153
+ this.upgradeFailed = true
154
+ }
155
+ this.upgrading = false
156
+ this.publish()
157
+ }
158
+
159
+ private publish(): void {
160
+ this.store.set(this.projection())
161
+ }
162
+ }
@@ -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
+ }
@@ -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')