martty 0.2.11

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/bin/dsh-tui.js +68 -0
  4. package/cordis.patch.yml +30 -0
  5. package/creator/cordis.patch.yml +6 -0
  6. package/creator/package.json +10 -0
  7. package/lib/acp-client-events.js +65 -0
  8. package/lib/acp-client.js +114 -0
  9. package/lib/acp-host.js +24 -0
  10. package/lib/acp-session-config.js +376 -0
  11. package/lib/acp-session-plan.js +196 -0
  12. package/lib/acp-session-stats.js +239 -0
  13. package/lib/agent.js +64 -0
  14. package/lib/boot.js +119 -0
  15. package/lib/client-process.js +11 -0
  16. package/lib/client-run.js +379 -0
  17. package/lib/cordis-protocol.js +51 -0
  18. package/lib/creator-overlay.js +77 -0
  19. package/lib/demo-skin.js +79 -0
  20. package/lib/ember.js +20 -0
  21. package/lib/index.js +226 -0
  22. package/lib/inspect.js +971 -0
  23. package/lib/jsonrpc-line-transport.js +155 -0
  24. package/lib/mux.js +281 -0
  25. package/lib/palettes/default.json +44 -0
  26. package/lib/palettes/ember.json +44 -0
  27. package/lib/plan-view.js +92 -0
  28. package/lib/profile-acp-client.js +11 -0
  29. package/lib/right-demo.js +55 -0
  30. package/lib/runner.js +94 -0
  31. package/lib/spawn-tui.js +179 -0
  32. package/lib/stats-view.js +90 -0
  33. package/lib/tui-commands.js +144 -0
  34. package/lib/tui-overlay.js +252 -0
  35. package/lib/tui-slots.js +351 -0
  36. package/lib/tui-theme.js +463 -0
  37. package/package.json +83 -0
  38. package/skills/tui-plugin-development/SKILL.md +172 -0
  39. package/vendor/darwin-arm64/dsh-tui +0 -0
  40. package/vendor/darwin-x64/dsh-tui +0 -0
  41. package/vendor/linux-arm64/dsh-tui +0 -0
  42. package/vendor/linux-x64/dsh-tui +0 -0
  43. package/vendor/win32-x64/dsh-tui.exe +0 -0
@@ -0,0 +1,252 @@
1
+ /** Native, semantic TUI overlays for Client Plugins. */
2
+
3
+ import { Service } from '@deepseek-ai/cordis'
4
+ import { CORDIS_METHODS } from './cordis-protocol.js'
5
+ import { validateNodes } from './tui-slots.js'
6
+
7
+ export const name = 'tui-overlay'
8
+ export const inject = []
9
+
10
+ const PROTOCOL = 0
11
+
12
+ class TuiOverlayService extends Service {
13
+ constructor(ctx, core) {
14
+ super(ctx, 'tuiOverlay')
15
+ this.core = core
16
+ }
17
+
18
+ openSlider(options, handlers) {
19
+ return this.core.openSlider(options, handlers)
20
+ }
21
+
22
+ openView(options, handlers) {
23
+ return this.core.openView(options, handlers)
24
+ }
25
+
26
+ dispatch(params) {
27
+ return this.core.dispatch(params)
28
+ }
29
+
30
+ active() {
31
+ return this.core.active()
32
+ }
33
+
34
+ bindNotify(notify) {
35
+ return this.core.bindNotify(notify)
36
+ }
37
+ }
38
+
39
+ function finite(value, path) {
40
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
41
+ throw new Error(`tuiOverlay.openSlider: ${path} must be a finite number`)
42
+ }
43
+ return value
44
+ }
45
+
46
+ function validateSlider(input) {
47
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
48
+ throw new Error('tuiOverlay.openSlider: options must be an object')
49
+ }
50
+ if (typeof input.id !== 'string' || input.id.length === 0) {
51
+ throw new Error('tuiOverlay.openSlider: id must be a non-empty string')
52
+ }
53
+ if (typeof input.title !== 'string' || input.title.length === 0) {
54
+ throw new Error('tuiOverlay.openSlider: title must be a non-empty string')
55
+ }
56
+ const min = finite(input.min, 'min')
57
+ const max = finite(input.max, 'max')
58
+ const step = finite(input.step, 'step')
59
+ const value = finite(input.value, 'value')
60
+ if (min >= max) throw new Error('tuiOverlay.openSlider: min must be less than max')
61
+ if (step <= 0) throw new Error('tuiOverlay.openSlider: step must be greater than zero')
62
+ const marks = input.marks === undefined ? [] : input.marks
63
+ if (!Array.isArray(marks)) throw new Error('tuiOverlay.openSlider: marks must be an array')
64
+ const validatedMarks = marks.map((mark, index) => {
65
+ if (mark === null || typeof mark !== 'object' || Array.isArray(mark)) {
66
+ throw new Error(`tuiOverlay.openSlider: marks[${index}] must be an object`)
67
+ }
68
+ const markValue = finite(mark.value, `marks[${index}].value`)
69
+ if (markValue < min || markValue > max) {
70
+ throw new Error(`tuiOverlay.openSlider: marks[${index}].value is outside the slider range`)
71
+ }
72
+ if (typeof mark.label !== 'string' || mark.label.length === 0) {
73
+ throw new Error(`tuiOverlay.openSlider: marks[${index}].label must be a non-empty string`)
74
+ }
75
+ if (mark.id !== undefined && (typeof mark.id !== 'string' || mark.id.length === 0)) {
76
+ throw new Error(`tuiOverlay.openSlider: marks[${index}].id must be a non-empty string`)
77
+ }
78
+ return {
79
+ value: markValue,
80
+ ...(mark.id === undefined ? {} : { id: mark.id }),
81
+ label: mark.label,
82
+ }
83
+ })
84
+ const snapToMarks = input.snapToMarks === true
85
+ if (snapToMarks && validatedMarks.length === 0) {
86
+ throw new Error('tuiOverlay.openSlider: snapToMarks needs at least one mark')
87
+ }
88
+ return {
89
+ kind: 'slider',
90
+ id: input.id,
91
+ title: input.title,
92
+ min,
93
+ max,
94
+ step,
95
+ marks: validatedMarks,
96
+ snapToMarks,
97
+ value: Math.min(max, Math.max(min, value)),
98
+ }
99
+ }
100
+
101
+ function validateView(input) {
102
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
103
+ throw new Error('tuiOverlay.openView: options must be an object')
104
+ }
105
+ if (typeof input.id !== 'string' || input.id.length === 0) {
106
+ throw new Error('tuiOverlay.openView: id must be a non-empty string')
107
+ }
108
+ if (typeof input.title !== 'string' || input.title.length === 0) {
109
+ throw new Error('tuiOverlay.openView: title must be a non-empty string')
110
+ }
111
+ return {
112
+ kind: 'view',
113
+ id: input.id,
114
+ title: input.title,
115
+ nodes: validateNodes(input.nodes, 'view.nodes'),
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @param {object} ctx
121
+ * @param {{ notify?: (method: string, params: object) => void }} [options]
122
+ */
123
+ export function installTuiOverlay(ctx, options = {}) {
124
+ const queue = []
125
+ let send = typeof options.notify === 'function' ? options.notify : undefined
126
+ let current = null
127
+
128
+ function emit(method, params) {
129
+ if (typeof send === 'function') send(method, params)
130
+ else queue.push({ method, params })
131
+ }
132
+
133
+ function bindNotify(notify) {
134
+ if (typeof notify !== 'function') throw new Error('tuiOverlay.bindNotify: notify must be a function')
135
+ send = notify
136
+ for (const item of queue.splice(0)) send(item.method, item.params)
137
+ }
138
+
139
+ function publish(overlay) {
140
+ emit(CORDIS_METHODS.overlayUpdate, { protocol: PROTOCOL, overlay })
141
+ }
142
+
143
+ function controllerFor(entry) {
144
+ return {
145
+ close() {
146
+ if (entry.closed) return
147
+ entry.closed = true
148
+ if (current === entry) {
149
+ current = null
150
+ publish(null)
151
+ }
152
+ },
153
+ }
154
+ }
155
+
156
+ function openSlider(options, handlers = {}) {
157
+ if (current !== null) {
158
+ throw new Error(`tuiOverlay.openSlider: overlay "${current.overlay.id}" is already open`)
159
+ }
160
+ const slider = validateSlider(options)
161
+ const entry = { overlay: slider, handlers, closed: false }
162
+ current = entry
163
+ publish({ ...slider, marks: slider.marks.map((mark) => ({ ...mark })) })
164
+ return controllerFor(entry)
165
+ }
166
+
167
+ function openView(options, handlers = {}) {
168
+ const view = validateView(options)
169
+ if (current !== null) {
170
+ if (current.overlay.kind === 'view' && current.overlay.id === view.id) {
171
+ current.overlay = view
172
+ current.handlers = handlers
173
+ publish(structuredClone(view))
174
+ return controllerFor(current)
175
+ }
176
+ throw new Error(`tuiOverlay.openView: overlay "${current.overlay.id}" is already open`)
177
+ }
178
+ const entry = { overlay: view, handlers, closed: false }
179
+ current = entry
180
+ publish(structuredClone(view))
181
+ return controllerFor(entry)
182
+ }
183
+
184
+ async function dispatch(params) {
185
+ if (params === null || typeof params !== 'object' || params.protocol !== PROTOCOL) {
186
+ throw new Error('tuiOverlay.dispatch: unsupported overlay event')
187
+ }
188
+ const entry = current
189
+ if (entry === null || entry.closed || params.id !== entry.overlay.id) {
190
+ throw new Error(`tuiOverlay.dispatch: overlay "${String(params.id)}" is not active`)
191
+ }
192
+
193
+ if (entry.overlay.kind === 'view') {
194
+ if (params.event !== 'cancel' && params.event !== 'submit') {
195
+ throw new Error(`tuiOverlay.dispatch: unknown view event "${String(params.event)}"`)
196
+ }
197
+ const handler = params.event === 'submit'
198
+ ? entry.handlers.onSubmit
199
+ : entry.handlers.onCancel
200
+ entry.closed = true
201
+ if (current === entry) {
202
+ current = null
203
+ publish(null)
204
+ }
205
+ return handler?.()
206
+ }
207
+
208
+ const slider = entry.overlay
209
+ const value = finite(params.value, 'event.value')
210
+ if (value < slider.min || value > slider.max) {
211
+ throw new Error('tuiOverlay.dispatch: event.value is outside the slider range')
212
+ }
213
+ slider.value = value
214
+ if (params.event === 'change') {
215
+ return entry.handlers.onChange?.(value)
216
+ }
217
+ if (params.event !== 'submit' && params.event !== 'cancel') {
218
+ throw new Error(`tuiOverlay.dispatch: unknown event "${String(params.event)}"`)
219
+ }
220
+ const mark = slider.marks.reduce((nearest, candidate) => {
221
+ if (nearest === undefined) return candidate
222
+ return Math.abs(candidate.value - value) < Math.abs(nearest.value - value)
223
+ ? candidate
224
+ : nearest
225
+ }, undefined)
226
+ const handler = params.event === 'submit'
227
+ ? entry.handlers.onSubmit
228
+ : entry.handlers.onCancel
229
+ const controller = { close: () => {} }
230
+ entry.closed = true
231
+ if (current === entry) {
232
+ current = null
233
+ publish(null)
234
+ }
235
+ return handler?.(value, mark, controller)
236
+ }
237
+
238
+ function active() {
239
+ return current === null ? null : structuredClone(current.overlay)
240
+ }
241
+
242
+ const core = { openSlider, openView, dispatch, active, bindNotify }
243
+ const service = typeof ctx.provide === 'function'
244
+ ? new TuiOverlayService(ctx, core)
245
+ : { openSlider, openView, dispatch, active, bindNotify }
246
+ if (typeof ctx.provide !== 'function') ctx.tuiOverlay = service
247
+ return service
248
+ }
249
+
250
+ export function apply(ctx) {
251
+ installTuiOverlay(ctx)
252
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Structured TUI shell slots. Plugins contribute TuiNode trees; the service
3
+ * owns validation, aggregation, lifecycle, and compositor snapshots.
4
+ */
5
+
6
+ import { Service } from '@deepseek-ai/cordis'
7
+ import { CORDIS_METHODS } from './cordis-protocol.js'
8
+
9
+ export const name = 'tui-slots'
10
+ export const inject = []
11
+
12
+ export const SLOT_NAMES = Object.freeze([
13
+ 'chrome.right',
14
+ 'conversation.input.dock',
15
+ 'conversation.composer.dock',
16
+ ])
17
+
18
+ const SLOT_DEFINITIONS = Object.freeze({
19
+ 'chrome.right': Object.freeze({ kind: 'list', scope: 'root' }),
20
+ 'conversation.input.dock': Object.freeze({ kind: 'list', scope: 'session' }),
21
+ 'conversation.composer.dock': Object.freeze({ kind: 'list', scope: 'session' }),
22
+ })
23
+
24
+ class TuiSlotsService extends Service {
25
+ constructor(ctx, core) {
26
+ super(ctx, 'tuiSlots')
27
+ this.core = core
28
+ }
29
+
30
+ inject(slot, callback) {
31
+ return this.core.inject(this.ctx, slot, callback)
32
+ }
33
+
34
+ register(options, nodes) {
35
+ return this.core.register(this.ctx, options, nodes)
36
+ }
37
+
38
+ list() {
39
+ return this.core.list()
40
+ }
41
+
42
+ bindNotify(notify) {
43
+ return this.core.bindNotify(notify)
44
+ }
45
+ }
46
+
47
+ const PROTOCOL = 0
48
+ const THEME_TOKENS = new Set([
49
+ 'bg', 'surface', 'panel', 'fg', 'fg_secondary', 'fg_tertiary', 'caption',
50
+ 'brand', 'brand_soft', 'bubble_bg', 'bubble_fg', 'border', 'code_bg', 'ok',
51
+ 'warn', 'err', 'hint', 'chip_bg',
52
+ ])
53
+
54
+ const NODE_FIELDS = Object.freeze({
55
+ group: { required: ['id', 'kind', 'children'], optional: ['title', 'tone'] },
56
+ markdown: { required: ['id', 'kind', 'text'], optional: ['streaming'] },
57
+ reasoning: { required: ['id', 'kind', 'text', 'done'], optional: ['seconds'] },
58
+ user: { required: ['id', 'kind', 'text'], optional: ['queued'] },
59
+ generic: { required: ['id', 'kind', 'title', 'body'], optional: ['status', 'action'] },
60
+ terminal: { required: ['id', 'kind', 'title', 'body'], optional: ['exit'] },
61
+ diff: { required: ['id', 'kind', 'title', 'unified'], optional: ['path'] },
62
+ image: { required: ['id', 'kind', 'name', 'mime'], optional: ['dataBase64'] },
63
+ notice: { required: ['id', 'kind', 'level', 'text'], optional: [] },
64
+ unknown: { required: ['id', 'kind', 'want'], optional: ['title', 'detail'] },
65
+ })
66
+
67
+ function object(value, path) {
68
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
69
+ throw new Error(`tuiSlots: ${path} must be an object`)
70
+ }
71
+ return value
72
+ }
73
+
74
+ function string(value, path, allowEmpty = true) {
75
+ if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
76
+ throw new Error(`tuiSlots: ${path} must be ${allowEmpty ? 'a string' : 'a non-empty string'}`)
77
+ }
78
+ }
79
+
80
+ function optionalString(value, path) {
81
+ if (value !== undefined) string(value, path)
82
+ }
83
+
84
+ function optionalBoolean(value, path) {
85
+ if (value !== undefined && typeof value !== 'boolean') {
86
+ throw new Error(`tuiSlots: ${path} must be a boolean`)
87
+ }
88
+ }
89
+
90
+ function optionalAction(value, path) {
91
+ if (value === undefined) return
92
+ object(value, path)
93
+ const extras = Object.keys(value).filter((key) => !['kind', 'name', 'args'].includes(key))
94
+ if (extras.length > 0) throw new Error(`tuiSlots: ${path} has unknown field(s) ${extras.join(', ')}`)
95
+ if (value.kind !== 'command') throw new Error(`tuiSlots: ${path}.kind must be command`)
96
+ string(value.name, `${path}.name`, false)
97
+ optionalString(value.args, `${path}.args`)
98
+ }
99
+
100
+ function validateNode(node, path, ids) {
101
+ object(node, path)
102
+ string(node.kind, `${path}.kind`, false)
103
+ const shape = NODE_FIELDS[node.kind]
104
+ if (shape === undefined) throw new Error(`tuiSlots: ${path}.kind "${node.kind}" is not supported`)
105
+ const fields = new Set([...shape.required, ...shape.optional])
106
+ const extras = Object.keys(node).filter((key) => !fields.has(key))
107
+ if (extras.length > 0) throw new Error(`tuiSlots: ${path} has unknown field(s) ${extras.join(', ')}`)
108
+ for (const field of shape.required) {
109
+ if (!Object.hasOwn(node, field)) throw new Error(`tuiSlots: ${path}.${field} is required`)
110
+ }
111
+ string(node.id, `${path}.id`, false)
112
+ if (ids.has(node.id)) throw new Error(`tuiSlots: duplicate node id "${node.id}"`)
113
+ ids.add(node.id)
114
+
115
+ switch (node.kind) {
116
+ case 'group':
117
+ optionalString(node.title, `${path}.title`)
118
+ if (node.tone !== undefined && !THEME_TOKENS.has(node.tone)) {
119
+ throw new Error(`tuiSlots: ${path}.tone must be a theme token`)
120
+ }
121
+ node.children = validateNodes(node.children, `${path}.children`, ids)
122
+ break
123
+ case 'markdown':
124
+ string(node.text, `${path}.text`)
125
+ optionalBoolean(node.streaming, `${path}.streaming`)
126
+ break
127
+ case 'reasoning':
128
+ string(node.text, `${path}.text`)
129
+ if (typeof node.done !== 'boolean') throw new Error(`tuiSlots: ${path}.done must be a boolean`)
130
+ if (node.seconds !== undefined && (typeof node.seconds !== 'number' || node.seconds < 0)) {
131
+ throw new Error(`tuiSlots: ${path}.seconds must be a non-negative number`)
132
+ }
133
+ break
134
+ case 'user':
135
+ string(node.text, `${path}.text`)
136
+ optionalBoolean(node.queued, `${path}.queued`)
137
+ break
138
+ case 'generic':
139
+ string(node.title, `${path}.title`)
140
+ string(node.body, `${path}.body`)
141
+ if (node.status !== undefined && !['running', 'ok', 'err'].includes(node.status)) {
142
+ throw new Error(`tuiSlots: ${path}.status must be running, ok, or err`)
143
+ }
144
+ optionalAction(node.action, `${path}.action`)
145
+ break
146
+ case 'terminal':
147
+ string(node.title, `${path}.title`)
148
+ string(node.body, `${path}.body`)
149
+ if (node.exit !== undefined && node.exit !== null && !Number.isInteger(node.exit)) {
150
+ throw new Error(`tuiSlots: ${path}.exit must be an integer or null`)
151
+ }
152
+ break
153
+ case 'diff':
154
+ string(node.title, `${path}.title`)
155
+ string(node.unified, `${path}.unified`)
156
+ optionalString(node.path, `${path}.path`)
157
+ break
158
+ case 'image':
159
+ string(node.name, `${path}.name`)
160
+ string(node.mime, `${path}.mime`)
161
+ optionalString(node.dataBase64, `${path}.dataBase64`)
162
+ break
163
+ case 'notice':
164
+ string(node.text, `${path}.text`)
165
+ if (!['info', 'warn', 'error'].includes(node.level)) {
166
+ throw new Error(`tuiSlots: ${path}.level must be info, warn, or error`)
167
+ }
168
+ break
169
+ case 'unknown':
170
+ string(node.want, `${path}.want`, false)
171
+ optionalString(node.title, `${path}.title`)
172
+ optionalString(node.detail, `${path}.detail`)
173
+ break
174
+ default:
175
+ }
176
+ return { ...node }
177
+ }
178
+
179
+ export function validateNodes(nodes, path = 'nodes', ids = new Set()) {
180
+ if (!Array.isArray(nodes)) throw new Error(`tuiSlots: ${path} must be an array`)
181
+ return nodes.map((node, index) => validateNode(structuredClone(node), `${path}[${index}]`, ids))
182
+ }
183
+
184
+ function namespaceNode(node, contributionId) {
185
+ const namespaced = { ...node, id: `${contributionId}:${node.id}` }
186
+ if (node.kind === 'group') {
187
+ namespaced.children = node.children.map((child) => namespaceNode(child, contributionId))
188
+ }
189
+ return namespaced
190
+ }
191
+
192
+ function disposerOf(value) {
193
+ if (typeof value === 'function') return value
194
+ if (value !== null && typeof value === 'object' && typeof value.dispose === 'function') {
195
+ return () => value.dispose()
196
+ }
197
+ return () => {}
198
+ }
199
+
200
+ /** Install the declared root-slot registry on a Cordis client context. */
201
+ export function installTuiSlots(ctx, options = {}) {
202
+ const contributions = new Map()
203
+ let nextSequence = 0
204
+ const revisions = new Map(SLOT_NAMES.map((slot) => [slot, 0]))
205
+ let send = typeof options.notify === 'function' ? options.notify : undefined
206
+ const queued = new Map()
207
+
208
+ function snapshot(slot) {
209
+ const nodes = [...contributions.values()]
210
+ .filter((entry) => entry.slot === slot)
211
+ .sort((left, right) => left.order - right.order || left.sequence - right.sequence)
212
+ .flatMap((entry) => entry.nodes.map((node) => namespaceNode(node, entry.id)))
213
+ const revision = (revisions.get(slot) ?? 0) + 1
214
+ revisions.set(slot, revision)
215
+ return { protocol: PROTOCOL, slot, rev: revision, nodes }
216
+ }
217
+
218
+ function publish(slot) {
219
+ const params = snapshot(slot)
220
+ if (typeof send === 'function') send(CORDIS_METHODS.slotsUpdate, params)
221
+ else queued.set(slot, params)
222
+ }
223
+
224
+ function bindNotify(notify) {
225
+ if (typeof notify !== 'function') throw new Error('tuiSlots.bindNotify: notify must be a function')
226
+ send = notify
227
+ for (const slot of SLOT_NAMES) {
228
+ const params = queued.get(slot)
229
+ if (params === undefined) continue
230
+ queued.delete(slot)
231
+ send(CORDIS_METHODS.slotsUpdate, params)
232
+ }
233
+ }
234
+
235
+ function register(effectCtx, options, nodes) {
236
+ object(options, 'register options')
237
+ const definition = SLOT_DEFINITIONS[options.name]
238
+ if (definition === undefined) {
239
+ throw new Error(
240
+ `tuiSlots.register: slot "${String(options.name)}" is not declared; use ${SLOT_NAMES.join(' or ')}`,
241
+ )
242
+ }
243
+ string(options.id, 'register options.id', false)
244
+ const extras = Object.keys(options).filter((key) => !['name', 'id', 'order'].includes(key))
245
+ if (extras.length > 0) throw new Error(`tuiSlots.register: unknown option(s) ${extras.join(', ')}`)
246
+ const order = options.order ?? 0
247
+ if (typeof order !== 'number' || !Number.isFinite(order)) {
248
+ throw new Error('tuiSlots.register: order must be a finite number')
249
+ }
250
+ const key = `${options.name}\u0000${options.id}`
251
+ if (contributions.has(key)) {
252
+ throw new Error(
253
+ `tuiSlots.register: contribution "${options.id}" is already registered in ${options.name}`,
254
+ )
255
+ }
256
+ if (definition.kind === 'single'
257
+ && [...contributions.values()].some((entry) => entry.slot === options.name)) {
258
+ throw new Error(`tuiSlots.register: single slot "${options.name}" is already occupied`)
259
+ }
260
+ const entry = {
261
+ key,
262
+ slot: options.name,
263
+ id: options.id,
264
+ order,
265
+ sequence: nextSequence++,
266
+ nodes: validateNodes(nodes),
267
+ active: false,
268
+ }
269
+ const setup = () => {
270
+ if (definition.kind === 'single'
271
+ && [...contributions.values()].some((candidate) => candidate.slot === entry.slot)) {
272
+ throw new Error(`tuiSlots.register: single slot "${entry.slot}" is already occupied`)
273
+ }
274
+ entry.active = true
275
+ contributions.set(entry.key, entry)
276
+ publish(entry.slot)
277
+ return () => {
278
+ if (!entry.active) return
279
+ entry.active = false
280
+ if (contributions.get(entry.key) === entry) contributions.delete(entry.key)
281
+ publish(entry.slot)
282
+ }
283
+ }
284
+ const releaseEffect = typeof effectCtx.effect === 'function'
285
+ ? effectCtx.effect(setup, `tuiSlots.register(${JSON.stringify(entry.id)})`)
286
+ : setup()
287
+ let disposed = false
288
+ const controller = {
289
+ update(nextNodes) {
290
+ if (disposed || !entry.active) throw new Error(`tuiSlots: contribution "${entry.id}" is disposed`)
291
+ entry.nodes = validateNodes(nextNodes)
292
+ publish(entry.slot)
293
+ },
294
+ dispose() {
295
+ if (disposed) return
296
+ disposed = true
297
+ return releaseEffect?.()
298
+ },
299
+ }
300
+ return controller
301
+ }
302
+
303
+ function inject(effectCtx, slot, callback) {
304
+ if (SLOT_DEFINITIONS[slot] === undefined) {
305
+ throw new Error(
306
+ `tuiSlots.inject: slot "${String(slot)}" is not declared; use ${SLOT_NAMES.join(' or ')}`,
307
+ )
308
+ }
309
+ if (typeof callback !== 'function') throw new Error('tuiSlots.inject: callback must be a function')
310
+ const setup = () => disposerOf(callback())
311
+ const releaseEffect = typeof effectCtx.effect === 'function'
312
+ ? effectCtx.effect(setup, `tuiSlots.inject(${JSON.stringify(slot)})`)
313
+ : setup()
314
+ let disposed = false
315
+ return () => {
316
+ if (disposed) return
317
+ disposed = true
318
+ return releaseEffect?.()
319
+ }
320
+ }
321
+
322
+ function list() {
323
+ return SLOT_NAMES.map((slot) => ({
324
+ name: slot,
325
+ ...SLOT_DEFINITIONS[slot],
326
+ occupants: [...contributions.values()]
327
+ .filter((entry) => entry.slot === slot)
328
+ .map(({ id, order }) => ({ id, order })),
329
+ }))
330
+ }
331
+
332
+ const core = { inject, register, list, bindNotify }
333
+ const service = typeof ctx.provide === 'function'
334
+ ? new TuiSlotsService(ctx, core)
335
+ : {
336
+ inject(slot, callback) {
337
+ return inject(ctx, slot, callback)
338
+ },
339
+ register(options, nodes) {
340
+ return register(ctx, options, nodes)
341
+ },
342
+ list,
343
+ bindNotify,
344
+ }
345
+ if (typeof ctx.provide !== 'function') ctx.tuiSlots = service
346
+ return service
347
+ }
348
+
349
+ export function apply(ctx) {
350
+ installTuiSlots(ctx)
351
+ }