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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/dsh-tui.js +68 -0
- package/cordis.patch.yml +30 -0
- package/creator/cordis.patch.yml +6 -0
- package/creator/package.json +10 -0
- package/lib/acp-client-events.js +65 -0
- package/lib/acp-client.js +114 -0
- package/lib/acp-host.js +24 -0
- package/lib/acp-session-config.js +376 -0
- package/lib/acp-session-plan.js +196 -0
- package/lib/acp-session-stats.js +239 -0
- package/lib/agent.js +64 -0
- package/lib/boot.js +119 -0
- package/lib/client-process.js +11 -0
- package/lib/client-run.js +379 -0
- package/lib/cordis-protocol.js +51 -0
- package/lib/creator-overlay.js +77 -0
- package/lib/demo-skin.js +79 -0
- package/lib/ember.js +20 -0
- package/lib/index.js +226 -0
- package/lib/inspect.js +971 -0
- package/lib/jsonrpc-line-transport.js +155 -0
- package/lib/mux.js +281 -0
- package/lib/palettes/default.json +44 -0
- package/lib/palettes/ember.json +44 -0
- package/lib/plan-view.js +92 -0
- package/lib/profile-acp-client.js +11 -0
- package/lib/right-demo.js +55 -0
- package/lib/runner.js +94 -0
- package/lib/spawn-tui.js +179 -0
- package/lib/stats-view.js +90 -0
- package/lib/tui-commands.js +144 -0
- package/lib/tui-overlay.js +252 -0
- package/lib/tui-slots.js +351 -0
- package/lib/tui-theme.js +463 -0
- package/package.json +83 -0
- package/skills/tui-plugin-development/SKILL.md +172 -0
- package/vendor/darwin-arm64/dsh-tui +0 -0
- package/vendor/darwin-x64/dsh-tui +0 -0
- package/vendor/linux-arm64/dsh-tui +0 -0
- package/vendor/linux-x64/dsh-tui +0 -0
- 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
|
+
}
|
package/lib/tui-slots.js
ADDED
|
@@ -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
|
+
}
|