@rs-dsh/dsh-core 0.0.0-stage → 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first release. dsh-threads 0.2.0 and dsh-quote 0.2.0 are built on it.
6
+
7
+ - **One selection toolbar** for every rs-dsh plugin's actions. A button shows only where its action applies.
8
+ - **Cards drawn with DSH's own components** (`MenuSurface`, `Button`, `InlineEditor`, `MarkdownText`, `TextShimmer`), closing on **Esc** or a click outside, one at a time.
9
+ - **A namespace per plugin** and a repository-wide test that keeps their styles apart.
10
+ - **Requires** DSH 0.2.1-rc.2 or later with the web GUI.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Romany Saad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @rs-dsh/dsh-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared base of the rs-dsh plugins for DeepSeek Harness. It gives them one selection toolbar, so their buttons share one box, and draws their boxes with DSH's own components. You don't install it yourself: [dsh-threads](https://github.com/romany-saad/dsh-plugins/tree/main/packages/dsh-threads) and [dsh-quote](https://github.com/romany-saad/dsh-plugins/tree/main/packages/dsh-quote) bring it with them.
4
+
5
+ ![Quote and Comment side by side in one toolbar above a selected sentence](https://raw.githubusercontent.com/romany-saad/dsh-plugins/main/packages/dsh-core/docs/images/one-toolbar.png)
6
+
7
+ ## Install
8
+
9
+ Install the plugins you want. Each one brings this package and adds it to the profile:
10
+
11
+ ```sh
12
+ dsh plugin --profile <name> add @rs-dsh/dsh-threads@0.2.0 @rs-dsh/dsh-quote@0.2.0
13
+ ```
14
+
15
+ Then reload the DSH web page. However many rs-dsh plugins you install, the core runs once.
16
+
17
+ ## Requirements
18
+
19
+ - DSH 0.2.1-rc.2 or later. It is tested with 0.2.1-rc.2.
20
+ - A profile with the web GUI (`@deepseek-ai/dsh-web-app`), such as `web`.
21
+
22
+ ## What you see
23
+
24
+ - **One selection toolbar.** Select text in a conversation and the actions of every installed rs-dsh plugin show in one box, such as **Quote** and **Comment**. An action only shows where it applies: for example, there's no **Comment** on tool output.
25
+ - **Boxes that look like DSH.** Buttons, text boxes, menus and replies are DSH's own components, so they follow DSH's theme and its updates. A box closes with **Esc** or a click outside it, and only one is open at a time.
26
+
27
+ ## Limits
28
+
29
+ - **Web GUI only.**
30
+ - **Needs DSH's UI library.** The core uses `@deepseek-ai/dsh-client-ui-primitives`, which DSH's web app ships. If a DSH release drops it or renames a part the core uses, the rs-dsh plugins stop at start with a message saying so, until they're updated.
31
+
32
+ ## More
33
+
34
+ - [CHANGELOG.md](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/CHANGELOG.md)
35
+ - [MIT License](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/LICENSE)
36
+ - For maintainers, and for writing a plugin on the core: [DEVELOPING.md](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/DEVELOPING.md)
package/client.js ADDED
@@ -0,0 +1,503 @@
1
+ // @rs-dsh/dsh-core: browser half (hand-written, no build step).
2
+ //
3
+ // The shared UI layer for rs-dsh plugins, built on DSH's own UI library
4
+ // (@deepseek-ai/dsh-client-ui-primitives) so the plugins look like DSH and
5
+ // follow its updates. It provides one service, `rsDshCore`:
6
+ //
7
+ // ui the DSH components and icons the plugins use
8
+ // selection.register() a button in the one selection toolbar
9
+ // popover.open() an anchored card (React) with outside-click and Escape
10
+ // style(plugin, css) one stylesheet per plugin, scoped by convention
11
+ // scope(plugin) the root attribute a plugin's elements carry
12
+ // selectionContext() what the current selection is over
13
+ //
14
+ // The library is assumed present: DSH's web shell ships it in its shared
15
+ // module table. If it is ever missing, the core fails loudly at start with
16
+ // one message instead of drawing look-alikes (see README "Limits").
17
+ window.__ModuleLoader__.load({
18
+ id: '@rs-dsh/dsh-core',
19
+ factory: (require) => {
20
+ 'use strict'
21
+ const module = { exports: {} }
22
+
23
+ const NS = 'dsh-core'
24
+ const ROOT = 'data-rs-dsh'
25
+ const PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
26
+
27
+ // The components and icons the rs-dsh plugins use. A test checks every
28
+ // name against the real library at the pinned DSH version.
29
+ const UI_EXPORTS = [
30
+ 'Button', 'MenuSurface', 'InlineEditor', 'TextShimmer', 'MarkdownText', 'Tooltip', 'Toast',
31
+ 'IconTrashOutlineRegular', 'IconSendOutlineRegular', 'IconCopyOutlineRegular', 'IconCloseOutlineRegular',
32
+ 'IconNewChatOutlineRegular', 'IconEditOutlineRegular', 'IconCheckOutlineRegular', 'IconRefreshOutlineRegular',
33
+ 'IconWarningOutlineRegular', 'IconChevronDownOutlineRegular', 'IconChevronUpOutlineRegular',
34
+ ]
35
+
36
+ // ------------------------------------------------------------ pure logic
37
+
38
+ /** The library's exports the plugins use; throws one clear error if any is gone. */
39
+ function pickUi(lib) {
40
+ if (lib === null || typeof lib !== 'object') {
41
+ throw new Error(`dsh-core: ${PRIMITIVES} is not available in this DSH build; the rs-dsh plugins need it`)
42
+ }
43
+ const missing = UI_EXPORTS.filter((name) => lib[name] === undefined)
44
+ if (missing.length > 0) {
45
+ throw new Error(`dsh-core: this DSH build's ${PRIMITIVES} lacks ${missing.join(', ')}; update the rs-dsh plugins`)
46
+ }
47
+ const ui = {}
48
+ for (const name of UI_EXPORTS) ui[name] = lib[name]
49
+ return ui
50
+ }
51
+
52
+ /** A plugin name usable in an attribute value and a CSS selector. */
53
+ function checkPlugin(plugin) {
54
+ if (typeof plugin !== 'string' || !/^[a-z][a-z0-9-]*$/.test(plugin)) {
55
+ throw new Error('dsh-core: plugin names are lowercase letters, digits and dashes: ' + JSON.stringify(plugin))
56
+ }
57
+ return plugin
58
+ }
59
+
60
+ /**
61
+ * Where to put a floating card of `size` next to `rect`, inside the
62
+ * viewport: above when it fits there (`prefer: 'above'`), else below.
63
+ */
64
+ function placeNear(rect, size, viewport, { prefer = 'below', gap = 8, align = 'start' } = {}) {
65
+ const margin = 8
66
+ const fitsBelow = rect.bottom + gap + size.height <= viewport.height - margin
67
+ const fitsAbove = rect.top - gap - size.height >= margin
68
+ const above = prefer === 'above' ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove
69
+ let top = above ? rect.top - gap - size.height : rect.bottom + gap
70
+ top = Math.max(margin, Math.min(top, viewport.height - size.height - margin))
71
+ let left = align === 'center' ? rect.left + rect.width / 2 - size.width / 2 : rect.left
72
+ left = Math.max(margin, Math.min(left, viewport.width - size.width - margin))
73
+ return { top: Math.round(top), left: Math.round(left), above }
74
+ }
75
+
76
+ /** Collapse whitespace and cut to n code points with an ellipsis; never forges editor placeholders. */
77
+ function oneLine(s, n) {
78
+ const flat = String(s).replace(/[\uE100-\uE11D\uFFFC]/gu, '').replace(/\s+/g, ' ').trim()
79
+ const chars = Array.from(flat)
80
+ return chars.length <= n ? flat : chars.slice(0, n - 1).join('') + '\u2026'
81
+ }
82
+
83
+ /**
84
+ * Insert one chip into a draft snapshot, after the chips already stacked
85
+ * at the top whose source is in `stackAfter` (by default its own), each on
86
+ * its own line, so chips keep the order they were added in. Other chips
87
+ * shift; text is kept. Returns the next snapshot (the shape DSH persists).
88
+ */
89
+ function stackChip(snapshot, insert, stackAfter = [insert.source]) {
90
+ const text = snapshot.text
91
+ const refs = snapshot.references.slice().sort((a, b) => a.offset - b.offset)
92
+ let at = 0
93
+ for (;;) {
94
+ const head = refs.find((r) => r.offset === at && stackAfter.includes(r.source))
95
+ if (head === undefined) break
96
+ at = head.offset + head.length
97
+ if (text.charAt(at) === '\n') at += 1
98
+ else break
99
+ }
100
+ const lead = at > 0 && text.charAt(at - 1) !== '\n' ? '\n' : ''
101
+ const added = lead + insert.clipboardText + '\n'
102
+ const offset = at + lead.length
103
+ const shifted = refs.map((r) => (r.offset >= at ? { ...r, offset: r.offset + added.length } : r))
104
+ const chip = {
105
+ source: insert.source,
106
+ ref: insert.ref,
107
+ offset,
108
+ length: insert.clipboardText.length,
109
+ label: insert.label,
110
+ clipboardText: insert.clipboardText,
111
+ }
112
+ const references = shifted.concat([chip]).sort((a, b) => a.offset - b.offset)
113
+ const next = { text: text.slice(0, at) + added + text.slice(at), references }
114
+ if (snapshot.command !== undefined) next.command = snapshot.command
115
+ return next
116
+ }
117
+
118
+ /** Toolbar buttons in order: by `order`, then by registration. */
119
+ function sortButtons(buttons) {
120
+ return buttons.slice().sort((a, b) => a.order - b.order || a.seq - b.seq)
121
+ }
122
+
123
+ /** Normalize a raw DOM selection string. */
124
+ function cleanSelection(raw) {
125
+ return String(raw).replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
126
+ }
127
+
128
+ /** Whether a pointer or key event happened inside `el`, across portals that keep a DOM path. */
129
+ function eventInside(event, el) {
130
+ if (el === null || el === undefined) return false
131
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : []
132
+ if (path.includes(el)) return true
133
+ return event.target instanceof Node && el.contains(event.target)
134
+ }
135
+
136
+ // ------------------------------------------------------------ strings
137
+
138
+ const en = {
139
+ actions: 'Selection actions',
140
+ busy: 'The composer is busy; try again in a moment',
141
+ command: 'This cannot go into a command draft',
142
+ noSession: 'No composer found for this conversation',
143
+ }
144
+ const zh = {
145
+ actions: '选中文本操作',
146
+ busy: '当前无法编辑输入框,稍后再试',
147
+ command: '命令草稿中不能插入',
148
+ noSession: '找不到该会话的输入框',
149
+ }
150
+
151
+ // The only stylesheet the core owns: positioning of its two floating hosts.
152
+ // Everything visible inside them is DSH's own components and tokens. The
153
+ // toolbar keeps the menu's translucent material; text-heavy cards take the
154
+ // solid layer of DSH's preview cards, so the page never shows through.
155
+ const STYLE = `
156
+ [${ROOT}="dsh-core"][data-part]{position:fixed;z-index:1200;max-width:calc(100vw - 16px)}
157
+ [${ROOT}="dsh-core"][data-part][hidden]{display:none}
158
+ [${ROOT}="dsh-core"] [data-part-card]{box-sizing:border-box;padding:4px;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);
159
+ box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);
160
+ --dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
161
+ [${ROOT}="dsh-core"][data-part="toolbar"] [data-part-card]{display:flex;gap:2px}
162
+ [${ROOT}="dsh-core"][data-part="popover"] [data-part-card]{padding:10px;display:flex;flex-direction:column;max-height:min(70vh,560px);
163
+ background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel)}
164
+ [${ROOT}="dsh-core"][data-part="popover"] [data-part-card]>[aria-hidden="true"]:first-child{display:none}
165
+ `
166
+ // ------------------------------------------------------------ plugin body
167
+
168
+ function apply(ctx) {
169
+ const React = require('react')
170
+ const { jsx, jsxs } = require('react/jsx-runtime')
171
+ const { createRoot } = require('react-dom/client')
172
+ let lib
173
+ try { lib = require(PRIMITIVES) } catch (_) { lib = null }
174
+ const ui = pickUi(lib)
175
+
176
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-core: dictionaries')
177
+ const t = ctx.locale.bind(NS)
178
+
179
+ // -------------------------------------------------- styles
180
+
181
+ const sheets = new Map() // plugin → <style>
182
+ /** One stylesheet per plugin; returns a disposer. A second call replaces the text. */
183
+ function style(plugin, css) {
184
+ checkPlugin(plugin)
185
+ let el = sheets.get(plugin)
186
+ if (el === undefined) {
187
+ el = document.createElement('style')
188
+ el.setAttribute('data-rs-dsh-style', plugin)
189
+ document.head.appendChild(el)
190
+ sheets.set(plugin, el)
191
+ }
192
+ el.textContent = css
193
+ return () => {
194
+ if (sheets.get(plugin) === el) { el.remove(); sheets.delete(plugin) }
195
+ }
196
+ }
197
+ const scope = (plugin) => ({ [ROOT]: checkPlugin(plugin) })
198
+ const selector = (plugin) => `[${ROOT}="${checkPlugin(plugin)}"]`
199
+
200
+ // -------------------------------------------------- composer
201
+
202
+ /** A session's composer when it can take a chip, or { error } in the user's language. */
203
+ function editableInput(sessionId) {
204
+ if (ctx.sessions === undefined || ctx.conversation === undefined) return { error: t('noSession') }
205
+ const actx = ctx.sessions.scope(sessionId)
206
+ if (actx === undefined) return { error: t('noSession') }
207
+ const input = ctx.conversation.input.for(actx)
208
+ const phase = input.state.getSnapshot().phase
209
+ if (phase === 'claimed') return { error: t('command') }
210
+ if (phase !== 'plain') return { error: t('busy') }
211
+ return { actx, input }
212
+ }
213
+
214
+ // Chip sources of the rs-dsh plugins; a new chip stacks after all of them.
215
+ const chipSources = new Set()
216
+ /** Declare a chip source as an rs-dsh one; returns a disposer. */
217
+ function claimChipSource(source) {
218
+ chipSources.add(source)
219
+ return () => chipSources.delete(source)
220
+ }
221
+
222
+ /** Stack a chip at the top of a session's draft. Returns null, or the reason it could not. */
223
+ function insertChip(sessionId, insert) {
224
+ const { actx, input, error } = editableInput(sessionId)
225
+ if (error !== undefined) return error
226
+ const snapshot = input.draftSnapshot
227
+ if (snapshot !== undefined && snapshot !== null && Array.isArray(snapshot.references)) {
228
+ if (snapshot.command !== undefined) return t('command')
229
+ input.setDraft(stackChip(snapshot, insert, [...chipSources, insert.source]))
230
+ } else {
231
+ const span = input.actions.captureInsertion()
232
+ if (actx.bail(actx, 'slash/input-insert-reference', { reference: insert, span }) !== true) return t('busy')
233
+ }
234
+ input.focus()
235
+ return null
236
+ }
237
+
238
+ /** Show a notice under a session's composer. */
239
+ function notify(sessionId, text, level = 'error') {
240
+ const actx = ctx.sessions?.scope(sessionId)
241
+ if (actx !== undefined) ctx.conversation.input.for(actx).notify(level, text)
242
+ }
243
+
244
+ // -------------------------------------------------- selection context
245
+
246
+ /**
247
+ * What the current selection is over, or null: the conversation it is
248
+ * in, the chat row, the range and its rectangle, and the cleaned text.
249
+ * Selections in the composer, in inputs or in rs-dsh's own UI are none.
250
+ */
251
+ function selectionContext() {
252
+ const sel = window.getSelection()
253
+ if (sel === null || sel.isCollapsed || sel.rangeCount === 0) return null
254
+ const range = sel.getRangeAt(0)
255
+ const node = range.commonAncestorContainer
256
+ const el = node instanceof Element ? node : node.parentElement
257
+ if (el === null) return null
258
+ const region = el.closest('[data-conversation-region="chat"][data-conversation-session]')
259
+ if (region === null) return null
260
+ if (el.closest(`[data-composer-card],[${ROOT}],input,textarea,[contenteditable="true"]`) !== null) return null
261
+ const text = cleanSelection(sel.toString())
262
+ if (text === '') return null
263
+ const rect = range.getBoundingClientRect()
264
+ if (rect.width === 0 && rect.height === 0) return null
265
+ return {
266
+ sessionId: region.getAttribute('data-conversation-session'),
267
+ region,
268
+ element: el,
269
+ row: el.closest('[data-chat-node-key]'),
270
+ range: range.cloneRange(),
271
+ rect,
272
+ text,
273
+ }
274
+ }
275
+
276
+ // -------------------------------------------------- popover host
277
+
278
+ // One anchored card at a time, rendered with React into a host of its
279
+ // own on <body>. Opening another closes the current one.
280
+ let current = null // { host, root, onClose, anchor }
281
+
282
+ function closePopover(reason = 'api') {
283
+ const open = current
284
+ if (open === null) return
285
+ current = null
286
+ // Close is often called from inside the card's own React event (Enter
287
+ // in an editor, a button). Detach now; unmount once React has finished
288
+ // that event, never during it.
289
+ open.host.remove()
290
+ setTimeout(() => open.root.unmount(), 0)
291
+ try { open.onClose?.(reason) } catch (error) { console.error(error) }
292
+ }
293
+
294
+ /**
295
+ * Open a card next to `anchor` (a DOMRect or () => DOMRect).
296
+ * `render(close)` returns the React content. Returns { close, update }.
297
+ */
298
+ function openPopover({ plugin, anchor, render, width = 360, prefer = 'below', align = 'start', onClose, label }) {
299
+ checkPlugin(plugin)
300
+ closePopover('replaced')
301
+ hideToolbar()
302
+ const host = document.createElement('div')
303
+ host.setAttribute(ROOT, 'dsh-core')
304
+ host.setAttribute('data-part', 'popover')
305
+ host.setAttribute('data-plugin', plugin)
306
+ host.setAttribute('role', 'dialog')
307
+ if (label) host.setAttribute('aria-label', label)
308
+ host.style.width = width + 'px'
309
+ host.style.visibility = 'hidden'
310
+ document.body.appendChild(host)
311
+ const root = createRoot(host)
312
+ const entry = { host, root, onClose, anchor, prefer, align }
313
+ current = entry
314
+ const close = (reason = 'api') => { if (current === entry) closePopover(reason) }
315
+ const place = () => {
316
+ if (current !== entry) return
317
+ const rect = typeof entry.anchor === 'function' ? entry.anchor() : entry.anchor
318
+ if (!rect) return
319
+ const box = host.getBoundingClientRect()
320
+ const at = placeNear(rect, { width: box.width || width, height: box.height || 200 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: entry.prefer, align: entry.align })
321
+ host.style.left = at.left + 'px'
322
+ host.style.top = at.top + 'px'
323
+ host.style.visibility = ''
324
+ }
325
+ const draw = () => {
326
+ root.render(jsx(ui.MenuSurface, { 'data-part-card': '', children: jsx(PopoverBody, { render, close, place }) }))
327
+ }
328
+ draw()
329
+ return { close, update: draw, place }
330
+ }
331
+
332
+ /** Re-places the card after each render, so it follows its content's size. */
333
+ function PopoverBody({ render, close, place }) {
334
+ React.useLayoutEffect(() => { place() })
335
+ return render(close)
336
+ }
337
+
338
+ // -------------------------------------------------- selection toolbar
339
+
340
+ const buttons = [] // { id, plugin, label, icon, order, seq, when, onSelect }
341
+ let seq = 0
342
+ let pending = null // selectionContext() the toolbar shows for
343
+ const toolbar = document.createElement('div')
344
+ toolbar.setAttribute(ROOT, 'dsh-core')
345
+ toolbar.setAttribute('data-part', 'toolbar')
346
+ toolbar.setAttribute('role', 'toolbar')
347
+ toolbar.hidden = true
348
+ const toolbarRoot = createRoot(toolbar)
349
+
350
+ /**
351
+ * Add a button to the selection toolbar.
352
+ * @param spec.id unique within the plugin
353
+ * @param spec.label text on the button
354
+ * @param spec.onSelect (selection) => void, with selectionContext()'s value
355
+ * @param spec.when optional (selection) => boolean; hidden when false
356
+ * @param spec.icon optional React node
357
+ * @param spec.order optional number, lower first (default 100)
358
+ * @returns a disposer
359
+ */
360
+ function registerButton(spec) {
361
+ const plugin = checkPlugin(spec.plugin)
362
+ if (typeof spec.id !== 'string' || spec.id === '' || typeof spec.onSelect !== 'function') {
363
+ throw new Error('dsh-core: a selection button needs plugin, id, label and onSelect')
364
+ }
365
+ if (buttons.some((b) => b.plugin === plugin && b.id === spec.id)) throw new Error(`dsh-core: ${plugin}/${spec.id} is already registered`)
366
+ const entry = { order: 100, ...spec, plugin, seq: seq++ }
367
+ buttons.push(entry)
368
+ if (pending !== null) showToolbar(pending)
369
+ return () => {
370
+ const at = buttons.indexOf(entry)
371
+ if (at !== -1) buttons.splice(at, 1)
372
+ if (pending !== null) showToolbar(pending)
373
+ }
374
+ }
375
+
376
+ function visibleButtons(target) {
377
+ return sortButtons(buttons).filter((b) => {
378
+ if (typeof b.when !== 'function') return true
379
+ try { return b.when(target) === true } catch (error) { console.error(error); return false }
380
+ })
381
+ }
382
+
383
+ function Toolbar({ target, items }) {
384
+ return jsx(React.Fragment, {
385
+ children: items.map((b) => jsx(ui.Button, {
386
+ variant: 'ghost',
387
+ size: 'sm',
388
+ icon: b.icon,
389
+ 'data-plugin': b.plugin,
390
+ 'data-action': b.id,
391
+ onMouseDown: (e) => e.preventDefault(),
392
+ onClick: () => {
393
+ hideToolbar()
394
+ try { b.onSelect(target) } catch (error) { console.error(error) }
395
+ },
396
+ children: typeof b.label === 'function' ? b.label() : b.label,
397
+ }, b.plugin + '/' + b.id)),
398
+ })
399
+ }
400
+
401
+ function showToolbar(target) {
402
+ const items = visibleButtons(target)
403
+ if (items.length === 0) { hideToolbar(); return }
404
+ pending = target
405
+ toolbar.setAttribute('aria-label', t('actions'))
406
+ toolbarRoot.render(jsx(ui.MenuSurface, { compact: true, 'data-part-card': '', children: jsx(Toolbar, { target, items }) }))
407
+ toolbar.hidden = false
408
+ requestAnimationFrame(() => {
409
+ if (pending !== target) return
410
+ const box = toolbar.getBoundingClientRect()
411
+ const at = placeNear(target.rect, { width: box.width || 120, height: box.height || 36 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: 'above', align: 'center' })
412
+ toolbar.style.left = at.left + 'px'
413
+ toolbar.style.top = at.top + 'px'
414
+ })
415
+ }
416
+
417
+ function hideToolbar() {
418
+ toolbar.hidden = true
419
+ pending = null
420
+ }
421
+
422
+ function refresh() {
423
+ if (current !== null) { hideToolbar(); return }
424
+ const target = selectionContext()
425
+ if (target === null) { hideToolbar(); return }
426
+ showToolbar(target)
427
+ }
428
+
429
+ let timer = null
430
+ const onSelectionChange = () => {
431
+ if (timer !== null) clearTimeout(timer)
432
+ timer = setTimeout(() => { timer = null; refresh() }, 120)
433
+ }
434
+ const onPointerDown = (e) => {
435
+ if (current !== null && !eventInside(e, current.host)) closePopover('outside')
436
+ }
437
+ const onKeyDown = (e) => {
438
+ if (e.key !== 'Escape') return
439
+ if (current !== null) { e.stopPropagation(); closePopover('escape'); return }
440
+ if (!toolbar.hidden) hideToolbar()
441
+ }
442
+ const onScroll = (e) => {
443
+ if (!toolbar.hidden) hideToolbar()
444
+ if (current !== null && !eventInside(e, current.host)) {
445
+ const rect = typeof current.anchor === 'function' ? current.anchor() : null
446
+ if (rect) {
447
+ const box = current.host.getBoundingClientRect()
448
+ const at = placeNear(rect, { width: box.width, height: box.height }, { width: window.innerWidth, height: window.innerHeight }, { prefer: current.prefer, align: current.align })
449
+ current.host.style.left = at.left + 'px'
450
+ current.host.style.top = at.top + 'px'
451
+ }
452
+ }
453
+ }
454
+
455
+ ctx.effect(() => {
456
+ const disposeStyle = style('dsh-core', STYLE)
457
+ document.body.appendChild(toolbar)
458
+ document.addEventListener('selectionchange', onSelectionChange)
459
+ document.addEventListener('pointerdown', onPointerDown, true)
460
+ document.addEventListener('keydown', onKeyDown, true)
461
+ window.addEventListener('scroll', onScroll, true)
462
+ window.addEventListener('resize', onScroll)
463
+ return () => {
464
+ if (timer !== null) clearTimeout(timer)
465
+ document.removeEventListener('selectionchange', onSelectionChange)
466
+ document.removeEventListener('pointerdown', onPointerDown, true)
467
+ document.removeEventListener('keydown', onKeyDown, true)
468
+ window.removeEventListener('scroll', onScroll, true)
469
+ window.removeEventListener('resize', onScroll)
470
+ closePopover('dispose')
471
+ toolbarRoot.unmount()
472
+ toolbar.remove()
473
+ disposeStyle()
474
+ }
475
+ }, 'dsh-core: selection toolbar and popover host')
476
+
477
+ const api = {
478
+ version: 1,
479
+ ui,
480
+ React,
481
+ jsx,
482
+ jsxs,
483
+ style,
484
+ scope,
485
+ selector,
486
+ selectionContext,
487
+ oneLine,
488
+ stackChip,
489
+ composer: { editable: editableInput, insertChip, notify, claimChipSource },
490
+ selection: { register: registerButton, hide: hideToolbar },
491
+ popover: { open: openPopover, close: () => closePopover('api'), isOpen: () => current !== null },
492
+ }
493
+ ctx.provide('rsDshCore', api)
494
+ }
495
+
496
+ module.exports.name = '@rs-dsh/dsh-core'
497
+ module.exports.inject = ['conversation', 'locale', 'sessions']
498
+ module.exports.apply = apply
499
+ // Pure helpers, exported for tests only.
500
+ module.exports._internals = { UI_EXPORTS, pickUi, checkPlugin, placeNear, sortButtons, cleanSelection, eventInside, oneLine, stackChip, ROOT }
501
+ return module.exports
502
+ },
503
+ })
@@ -0,0 +1,5 @@
1
+ # Insert the core into the profile roster. Every rs-dsh plugin that uses the
2
+ # core inserts this same row; the loader starts it once.
3
+ - insert:
4
+ - id: dsh-core
5
+ name: '@rs-dsh/dsh-core'
package/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @rs-dsh/dsh-core: Node half. Everything runs in the browser bundle
2
+ // (client.js); the host side has nothing to do. The row exists so the
3
+ // client half loads once, however many rs-dsh plugins insert it.
4
+ export const name = 'dsh-core'
5
+
6
+ export function apply() {}
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@rs-dsh/dsh-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Shared UI layer for the rs-dsh DSH plugins: one selection toolbar, anchored cards and DSH's own components, so the plugins look like DSH and stay small.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/romany-saad/dsh-plugins.git",
10
+ "directory": "packages/dsh-core"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "deepseek-harness"
20
+ ],
21
+ "main": "index.js",
22
+ "exports": {
23
+ ".": "./index.js",
24
+ "./client": "./client.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "client.js",
30
+ "cordis.patch.yml",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web",
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-locale"
43
+ ]
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.1-rc.2"
48
+ },
49
+ "devDependencies": {
50
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.2",
51
+ "jsdom": "26.1.0",
52
+ "react": "19.2.0",
53
+ "react-dom": "19.2.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=22"
57
+ },
58
+ "scripts": {
59
+ "test": "node --check client.js && node --import ./test/no-network.mjs --test test/*.test.js"
60
+ }
6
61
  }