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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - **Chinese wording** reviewed: messages follow DSH's own terms (指令 for commands) and end with 。.
6
+ - **Triple-click selections** now get the Comment button. A triple-click on a message's last paragraph runs on to the start of the next message, so the selection belonged to no message; it is now trimmed to the text it covers.
7
+ - **Cards focus their text field** when they open, so you can type a comment or a quote note right away.
8
+ - **The selected passage stays highlighted** while its comment or quote card is open, though focusing the field clears the page selection.
9
+
10
+ ## 0.1.0
11
+
12
+ The first release. dsh-threads 0.2.0 and dsh-quote 0.2.0 are built on it.
13
+
14
+ - **One selection toolbar** for every rs-dsh plugin's actions. A button shows only where its action applies.
15
+ - **Cards drawn with DSH's own components** (`MenuSurface`, `Button`, `InlineEditor`, `MarkdownText`, `TextShimmer`), closing on **Esc** or a click outside, one at a time.
16
+ - **A namespace per plugin** and a repository-wide test that keeps their styles apart.
17
+ - **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.3.0 @rs-dsh/dsh-quote@0.2.1
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,566 @@
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
+ // The passage a toolbar action was taken on, painted while its card is open.
156
+ const HELD = 'dsh-core-held'
157
+ const STYLE = `
158
+ [${ROOT}="dsh-core"][data-part]{position:fixed;z-index:1200;max-width:calc(100vw - 16px)}
159
+ [${ROOT}="dsh-core"][data-part][hidden]{display:none}
160
+ [${ROOT}="dsh-core"] [data-part-card]{box-sizing:border-box;padding:4px;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);
161
+ box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);
162
+ --dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
163
+ [${ROOT}="dsh-core"][data-part="toolbar"] [data-part-card]{display:flex;gap:2px}
164
+ [${ROOT}="dsh-core"][data-part="popover"] [data-part-card]{padding:10px;display:flex;flex-direction:column;max-height:min(70vh,560px);
165
+ background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel)}
166
+ [${ROOT}="dsh-core"][data-part="popover"] [data-part-card]>[aria-hidden="true"]:first-child{display:none}
167
+ ::highlight(${HELD}){background-color:Highlight;color:HighlightText}
168
+ `
169
+ // ------------------------------------------------------------ plugin body
170
+
171
+ function apply(ctx) {
172
+ const React = require('react')
173
+ const { jsx, jsxs } = require('react/jsx-runtime')
174
+ const { createRoot } = require('react-dom/client')
175
+ let lib
176
+ try { lib = require(PRIMITIVES) } catch (_) { lib = null }
177
+ const ui = pickUi(lib)
178
+
179
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-core: dictionaries')
180
+ const t = ctx.locale.bind(NS)
181
+
182
+ // -------------------------------------------------- styles
183
+
184
+ const sheets = new Map() // plugin → <style>
185
+ /** One stylesheet per plugin; returns a disposer. A second call replaces the text. */
186
+ function style(plugin, css) {
187
+ checkPlugin(plugin)
188
+ let el = sheets.get(plugin)
189
+ if (el === undefined) {
190
+ el = document.createElement('style')
191
+ el.setAttribute('data-rs-dsh-style', plugin)
192
+ document.head.appendChild(el)
193
+ sheets.set(plugin, el)
194
+ }
195
+ el.textContent = css
196
+ return () => {
197
+ if (sheets.get(plugin) === el) { el.remove(); sheets.delete(plugin) }
198
+ }
199
+ }
200
+ const scope = (plugin) => ({ [ROOT]: checkPlugin(plugin) })
201
+ const selector = (plugin) => `[${ROOT}="${checkPlugin(plugin)}"]`
202
+
203
+ // -------------------------------------------------- composer
204
+
205
+ /** A session's composer when it can take a chip, or { error } in the user's language. */
206
+ function editableInput(sessionId) {
207
+ if (ctx.sessions === undefined || ctx.conversation === undefined) return { error: t('noSession') }
208
+ const actx = ctx.sessions.scope(sessionId)
209
+ if (actx === undefined) return { error: t('noSession') }
210
+ const input = ctx.conversation.input.for(actx)
211
+ const phase = input.state.getSnapshot().phase
212
+ if (phase === 'claimed') return { error: t('command') }
213
+ if (phase !== 'plain') return { error: t('busy') }
214
+ return { actx, input }
215
+ }
216
+
217
+ // Chip sources of the rs-dsh plugins; a new chip stacks after all of them.
218
+ const chipSources = new Set()
219
+ /** Declare a chip source as an rs-dsh one; returns a disposer. */
220
+ function claimChipSource(source) {
221
+ chipSources.add(source)
222
+ return () => chipSources.delete(source)
223
+ }
224
+
225
+ /** Stack a chip at the top of a session's draft. Returns null, or the reason it could not. */
226
+ function insertChip(sessionId, insert) {
227
+ const { actx, input, error } = editableInput(sessionId)
228
+ if (error !== undefined) return error
229
+ const snapshot = input.draftSnapshot
230
+ if (snapshot !== undefined && snapshot !== null && Array.isArray(snapshot.references)) {
231
+ if (snapshot.command !== undefined) return t('command')
232
+ input.setDraft(stackChip(snapshot, insert, [...chipSources, insert.source]))
233
+ } else {
234
+ const span = input.actions.captureInsertion()
235
+ if (actx.bail(actx, 'slash/input-insert-reference', { reference: insert, span }) !== true) return t('busy')
236
+ }
237
+ input.focus()
238
+ return null
239
+ }
240
+
241
+ /** Show a notice under a session's composer. */
242
+ function notify(sessionId, text, level = 'error') {
243
+ const actx = ctx.sessions?.scope(sessionId)
244
+ if (actx !== undefined) ctx.conversation.input.for(actx).notify(level, text)
245
+ }
246
+
247
+ // -------------------------------------------------- selection context
248
+
249
+ /**
250
+ * What the current selection is over, or null: the conversation it is
251
+ * in, the chat row, the range and its rectangle, and the cleaned text.
252
+ * Selections in the composer, in inputs or in rs-dsh's own UI are none.
253
+ */
254
+ /**
255
+ * The range shrunk to the text it actually covers. A triple-click on
256
+ * the last paragraph of a message ends the range at offset 0 of the
257
+ * next message, so its common ancestor would be the whole chat and the
258
+ * selection would belong to no row.
259
+ */
260
+ function trimRange(range) {
261
+ const root = range.commonAncestorContainer
262
+ if (root.nodeType === 3 || typeof document.createTreeWalker !== 'function') return range
263
+ const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */)
264
+ let first = null, last = null
265
+ for (let n = walker.nextNode(); n !== null; n = walker.nextNode()) {
266
+ if (!range.intersectsNode(n)) continue
267
+ const start = n === range.startContainer ? range.startOffset : 0
268
+ const end = n === range.endContainer ? range.endOffset : n.data.length
269
+ if (start >= end || n.data.slice(start, end).trim() === '') continue
270
+ if (first === null) first = { n, start }
271
+ last = { n, end }
272
+ }
273
+ if (first === null) return range
274
+ const trimmed = range.cloneRange()
275
+ trimmed.setStart(first.n, first.start)
276
+ trimmed.setEnd(last.n, last.end)
277
+ return trimmed
278
+ }
279
+
280
+ function selectionContext() {
281
+ const sel = window.getSelection()
282
+ if (sel === null || sel.isCollapsed || sel.rangeCount === 0) return null
283
+ const range = trimRange(sel.getRangeAt(0))
284
+ const node = range.commonAncestorContainer
285
+ const el = node instanceof Element ? node : node.parentElement
286
+ if (el === null) return null
287
+ const region = el.closest('[data-conversation-region="chat"][data-conversation-session]')
288
+ if (region === null) return null
289
+ if (el.closest(`[data-composer-card],[${ROOT}],input,textarea,[contenteditable="true"]`) !== null) return null
290
+ const text = cleanSelection(sel.toString())
291
+ if (text === '') return null
292
+ const rect = range.getBoundingClientRect()
293
+ if (rect.width === 0 && rect.height === 0) return null
294
+ return {
295
+ sessionId: region.getAttribute('data-conversation-session'),
296
+ region,
297
+ element: el,
298
+ row: el.closest('[data-chat-node-key]'),
299
+ range: range.cloneRange(),
300
+ rect,
301
+ text,
302
+ }
303
+ }
304
+
305
+ // -------------------------------------------------- popover host
306
+
307
+ // One anchored card at a time, rendered with React into a host of its
308
+ // own on <body>. Opening another closes the current one.
309
+ let current = null // { host, root, onClose, anchor }
310
+
311
+ function closePopover(reason = 'api') {
312
+ const open = current
313
+ if (open === null) return
314
+ current = null
315
+ if (open.held) releaseSelection()
316
+ // Close is often called from inside the card's own React event (Enter
317
+ // in an editor, a button). Detach now; unmount once React has finished
318
+ // that event, never during it.
319
+ open.host.remove()
320
+ setTimeout(() => open.root.unmount(), 0)
321
+ try { open.onClose?.(reason) } catch (error) { console.error(error) }
322
+ }
323
+
324
+ /**
325
+ * Open a card next to `anchor` (a DOMRect or () => DOMRect).
326
+ * `render(close)` returns the React content. Returns { close, update }.
327
+ */
328
+ function openPopover({ plugin, anchor, render, width = 360, prefer = 'below', align = 'start', onClose, label }) {
329
+ checkPlugin(plugin)
330
+ closePopover('replaced')
331
+ hideToolbar()
332
+ const host = document.createElement('div')
333
+ host.setAttribute(ROOT, 'dsh-core')
334
+ host.setAttribute('data-part', 'popover')
335
+ host.setAttribute('data-plugin', plugin)
336
+ host.setAttribute('role', 'dialog')
337
+ if (label) host.setAttribute('aria-label', label)
338
+ host.style.width = width + 'px'
339
+ host.style.visibility = 'hidden'
340
+ document.body.appendChild(host)
341
+ const root = createRoot(host)
342
+ const entry = { host, root, onClose, anchor, prefer, align }
343
+ current = entry
344
+ const close = (reason = 'api') => { if (current === entry) closePopover(reason) }
345
+ const place = () => {
346
+ if (current !== entry) return
347
+ const rect = typeof entry.anchor === 'function' ? entry.anchor() : entry.anchor
348
+ if (!rect) return
349
+ const box = host.getBoundingClientRect()
350
+ const at = placeNear(rect, { width: box.width || width, height: box.height || 200 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: entry.prefer, align: entry.align })
351
+ host.style.left = at.left + 'px'
352
+ host.style.top = at.top + 'px'
353
+ host.style.visibility = ''
354
+ // The card mounts hidden until placed, so a field's autoFocus fires
355
+ // while it cannot take focus. Focus its first field once it shows.
356
+ if (!entry.focused) {
357
+ entry.focused = true
358
+ if (!host.contains(document.activeElement)) {
359
+ const field = host.querySelector('textarea,input:not([type="hidden"]),[contenteditable="true"]')
360
+ if (field !== null) {
361
+ field.focus({ preventScroll: true })
362
+ if (typeof field.setSelectionRange === 'function') field.setSelectionRange(field.value.length, field.value.length)
363
+ }
364
+ }
365
+ }
366
+ }
367
+ const draw = () => {
368
+ root.render(jsx(ui.MenuSurface, { 'data-part-card': '', children: jsx(PopoverBody, { render, close, place }) }))
369
+ }
370
+ draw()
371
+ return { close, update: draw, place }
372
+ }
373
+
374
+ /** Re-places the card after each render, so it follows its content's size. */
375
+ function PopoverBody({ render, close, place }) {
376
+ React.useLayoutEffect(() => { place() })
377
+ return render(close)
378
+ }
379
+
380
+ // -------------------------------------------------- held selection
381
+ //
382
+ // Focusing the card's field takes the page selection away. So when a
383
+ // toolbar action opens a card, the passage stays painted with a CSS
384
+ // highlight, in the selection's colours, until the card closes.
385
+
386
+ const canHold = typeof Highlight === 'function' && window.CSS !== undefined && window.CSS.highlights !== undefined
387
+
388
+ function holdSelection(range) {
389
+ if (!canHold || !range) return false
390
+ window.CSS.highlights.set(HELD, new Highlight(range))
391
+ return true
392
+ }
393
+
394
+ function releaseSelection() {
395
+ if (canHold) window.CSS.highlights.delete(HELD)
396
+ }
397
+
398
+ // -------------------------------------------------- selection toolbar
399
+
400
+ const buttons = [] // { id, plugin, label, icon, order, seq, when, onSelect }
401
+ let seq = 0
402
+ let pending = null // selectionContext() the toolbar shows for
403
+ const toolbar = document.createElement('div')
404
+ toolbar.setAttribute(ROOT, 'dsh-core')
405
+ toolbar.setAttribute('data-part', 'toolbar')
406
+ toolbar.setAttribute('role', 'toolbar')
407
+ toolbar.hidden = true
408
+ const toolbarRoot = createRoot(toolbar)
409
+
410
+ /**
411
+ * Add a button to the selection toolbar.
412
+ * @param spec.id unique within the plugin
413
+ * @param spec.label text on the button
414
+ * @param spec.onSelect (selection) => void, with selectionContext()'s value
415
+ * @param spec.when optional (selection) => boolean; hidden when false
416
+ * @param spec.icon optional React node
417
+ * @param spec.order optional number, lower first (default 100)
418
+ * @returns a disposer
419
+ */
420
+ function registerButton(spec) {
421
+ const plugin = checkPlugin(spec.plugin)
422
+ if (typeof spec.id !== 'string' || spec.id === '' || typeof spec.onSelect !== 'function') {
423
+ throw new Error('dsh-core: a selection button needs plugin, id, label and onSelect')
424
+ }
425
+ if (buttons.some((b) => b.plugin === plugin && b.id === spec.id)) throw new Error(`dsh-core: ${plugin}/${spec.id} is already registered`)
426
+ const entry = { order: 100, ...spec, plugin, seq: seq++ }
427
+ buttons.push(entry)
428
+ if (pending !== null) showToolbar(pending)
429
+ return () => {
430
+ const at = buttons.indexOf(entry)
431
+ if (at !== -1) buttons.splice(at, 1)
432
+ if (pending !== null) showToolbar(pending)
433
+ }
434
+ }
435
+
436
+ function visibleButtons(target) {
437
+ return sortButtons(buttons).filter((b) => {
438
+ if (typeof b.when !== 'function') return true
439
+ try { return b.when(target) === true } catch (error) { console.error(error); return false }
440
+ })
441
+ }
442
+
443
+ function Toolbar({ target, items }) {
444
+ return jsx(React.Fragment, {
445
+ children: items.map((b) => jsx(ui.Button, {
446
+ variant: 'ghost',
447
+ size: 'sm',
448
+ icon: b.icon,
449
+ 'data-plugin': b.plugin,
450
+ 'data-action': b.id,
451
+ onMouseDown: (e) => e.preventDefault(),
452
+ onClick: () => {
453
+ hideToolbar()
454
+ const before = current
455
+ try { b.onSelect(target) } catch (error) { console.error(error) }
456
+ // The action opened a card: keep its passage painted while it is open.
457
+ if (current !== null && current !== before) current.held = holdSelection(target.range)
458
+ },
459
+ children: typeof b.label === 'function' ? b.label() : b.label,
460
+ }, b.plugin + '/' + b.id)),
461
+ })
462
+ }
463
+
464
+ function showToolbar(target) {
465
+ const items = visibleButtons(target)
466
+ if (items.length === 0) { hideToolbar(); return }
467
+ pending = target
468
+ toolbar.setAttribute('aria-label', t('actions'))
469
+ toolbarRoot.render(jsx(ui.MenuSurface, { compact: true, 'data-part-card': '', children: jsx(Toolbar, { target, items }) }))
470
+ toolbar.hidden = false
471
+ requestAnimationFrame(() => {
472
+ if (pending !== target) return
473
+ const box = toolbar.getBoundingClientRect()
474
+ const at = placeNear(target.rect, { width: box.width || 120, height: box.height || 36 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: 'above', align: 'center' })
475
+ toolbar.style.left = at.left + 'px'
476
+ toolbar.style.top = at.top + 'px'
477
+ })
478
+ }
479
+
480
+ function hideToolbar() {
481
+ toolbar.hidden = true
482
+ pending = null
483
+ }
484
+
485
+ function refresh() {
486
+ if (current !== null) { hideToolbar(); return }
487
+ const target = selectionContext()
488
+ if (target === null) { hideToolbar(); return }
489
+ showToolbar(target)
490
+ }
491
+
492
+ let timer = null
493
+ const onSelectionChange = () => {
494
+ if (timer !== null) clearTimeout(timer)
495
+ timer = setTimeout(() => { timer = null; refresh() }, 120)
496
+ }
497
+ const onPointerDown = (e) => {
498
+ if (current !== null && !eventInside(e, current.host)) closePopover('outside')
499
+ }
500
+ const onKeyDown = (e) => {
501
+ if (e.key !== 'Escape') return
502
+ if (current !== null) { e.stopPropagation(); closePopover('escape'); return }
503
+ if (!toolbar.hidden) hideToolbar()
504
+ }
505
+ const onScroll = (e) => {
506
+ if (!toolbar.hidden) hideToolbar()
507
+ if (current !== null && !eventInside(e, current.host)) {
508
+ const rect = typeof current.anchor === 'function' ? current.anchor() : null
509
+ if (rect) {
510
+ const box = current.host.getBoundingClientRect()
511
+ const at = placeNear(rect, { width: box.width, height: box.height }, { width: window.innerWidth, height: window.innerHeight }, { prefer: current.prefer, align: current.align })
512
+ current.host.style.left = at.left + 'px'
513
+ current.host.style.top = at.top + 'px'
514
+ }
515
+ }
516
+ }
517
+
518
+ ctx.effect(() => {
519
+ const disposeStyle = style('dsh-core', STYLE)
520
+ document.body.appendChild(toolbar)
521
+ document.addEventListener('selectionchange', onSelectionChange)
522
+ document.addEventListener('pointerdown', onPointerDown, true)
523
+ document.addEventListener('keydown', onKeyDown, true)
524
+ window.addEventListener('scroll', onScroll, true)
525
+ window.addEventListener('resize', onScroll)
526
+ return () => {
527
+ if (timer !== null) clearTimeout(timer)
528
+ document.removeEventListener('selectionchange', onSelectionChange)
529
+ document.removeEventListener('pointerdown', onPointerDown, true)
530
+ document.removeEventListener('keydown', onKeyDown, true)
531
+ window.removeEventListener('scroll', onScroll, true)
532
+ window.removeEventListener('resize', onScroll)
533
+ closePopover('dispose')
534
+ toolbarRoot.unmount()
535
+ toolbar.remove()
536
+ disposeStyle()
537
+ }
538
+ }, 'dsh-core: selection toolbar and popover host')
539
+
540
+ const api = {
541
+ version: 1,
542
+ ui,
543
+ React,
544
+ jsx,
545
+ jsxs,
546
+ style,
547
+ scope,
548
+ selector,
549
+ selectionContext,
550
+ oneLine,
551
+ stackChip,
552
+ composer: { editable: editableInput, insertChip, notify, claimChipSource },
553
+ selection: { register: registerButton, hide: hideToolbar },
554
+ popover: { open: openPopover, close: () => closePopover('api'), isOpen: () => current !== null },
555
+ }
556
+ ctx.provide('rsDshCore', api)
557
+ }
558
+
559
+ module.exports.name = '@rs-dsh/dsh-core'
560
+ module.exports.inject = ['conversation', 'locale', 'sessions']
561
+ module.exports.apply = apply
562
+ // Pure helpers, exported for tests only.
563
+ module.exports._internals = { UI_EXPORTS, pickUi, checkPlugin, placeNear, sortButtons, cleanSelection, eventInside, oneLine, stackChip, ROOT }
564
+ return module.exports
565
+ },
566
+ })
@@ -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.1",
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
  }