@seed-ship/mcp-ui-solid 5.0.0 → 5.2.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +160 -6
  3. package/dist/components/ChatPrompt.cjs +71 -53
  4. package/dist/components/ChatPrompt.cjs.map +1 -1
  5. package/dist/components/ChatPrompt.d.ts +37 -2
  6. package/dist/components/ChatPrompt.d.ts.map +1 -1
  7. package/dist/components/ChatPrompt.js +72 -54
  8. package/dist/components/ChatPrompt.js.map +1 -1
  9. package/dist/components/FeedbackInline.cjs +57 -0
  10. package/dist/components/FeedbackInline.cjs.map +1 -0
  11. package/dist/components/FeedbackInline.d.ts +71 -0
  12. package/dist/components/FeedbackInline.d.ts.map +1 -0
  13. package/dist/components/FeedbackInline.js +57 -0
  14. package/dist/components/FeedbackInline.js.map +1 -0
  15. package/dist/index.cjs +9 -0
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +8 -2
  18. package/dist/index.d.ts +8 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/services/chat-bus.cjs +71 -0
  23. package/dist/services/chat-bus.cjs.map +1 -1
  24. package/dist/services/chat-bus.d.ts +31 -1
  25. package/dist/services/chat-bus.d.ts.map +1 -1
  26. package/dist/services/chat-bus.js +71 -0
  27. package/dist/services/chat-bus.js.map +1 -1
  28. package/dist/services/chat-prompt-controller.cjs +83 -0
  29. package/dist/services/chat-prompt-controller.cjs.map +1 -0
  30. package/dist/services/chat-prompt-controller.d.ts +93 -0
  31. package/dist/services/chat-prompt-controller.d.ts.map +1 -0
  32. package/dist/services/chat-prompt-controller.js +83 -0
  33. package/dist/services/chat-prompt-controller.js.map +1 -0
  34. package/dist/stores/scratchpad-store.cjs +105 -77
  35. package/dist/stores/scratchpad-store.cjs.map +1 -1
  36. package/dist/stores/scratchpad-store.d.ts +88 -19
  37. package/dist/stores/scratchpad-store.d.ts.map +1 -1
  38. package/dist/stores/scratchpad-store.js +105 -77
  39. package/dist/stores/scratchpad-store.js.map +1 -1
  40. package/dist/types/chat-bus.d.ts +164 -22
  41. package/dist/types/chat-bus.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/ChatPrompt.test.tsx +122 -0
  44. package/src/components/ChatPrompt.tsx +70 -15
  45. package/src/components/FeedbackInline.test.tsx +117 -0
  46. package/src/components/FeedbackInline.tsx +143 -0
  47. package/src/index.ts +24 -1
  48. package/src/services/chat-bus.test.ts +154 -2
  49. package/src/services/chat-bus.ts +115 -0
  50. package/src/services/chat-prompt-controller.test.ts +144 -0
  51. package/src/services/chat-prompt-controller.ts +214 -0
  52. package/src/stores/scratchpad-store.test.tsx +140 -0
  53. package/src/stores/scratchpad-store.tsx +244 -0
  54. package/src/types/chat-bus.ts +166 -22
  55. package/tsconfig.tsbuildinfo +1 -1
  56. package/src/stores/scratchpad-store.ts +0 -126
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Tests for scratchpad-store — v5.2.0 createScratchpadStore factory + provider
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
6
+ import { render, cleanup } from '@solidjs/testing-library'
7
+ import {
8
+ createScratchpadStore,
9
+ dispatchScratchpad,
10
+ useScratchpadState,
11
+ ScratchpadStoreProvider,
12
+ } from './scratchpad-store'
13
+ import type { ScratchpadEvent } from '../types/chat-bus'
14
+
15
+ const createEvent = (id: string, title: string): ScratchpadEvent => ({
16
+ id,
17
+ action: 'create',
18
+ title,
19
+ sections: [],
20
+ status: 'ready',
21
+ })
22
+
23
+ describe('createScratchpadStore — v5.2.0', () => {
24
+ beforeEach(() => {
25
+ cleanup()
26
+ // Silence the info/warn logs that dispatch emits
27
+ vi.spyOn(console, 'info').mockImplementation(() => {})
28
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
29
+ })
30
+
31
+ it('two stores do not share state', () => {
32
+ const storeA = createScratchpadStore()
33
+ const storeB = createScratchpadStore()
34
+
35
+ storeA.dispatch(createEvent('a', 'Store A'))
36
+
37
+ expect(storeA.state()?.id).toBe('a')
38
+ expect(storeA.state()?.title).toBe('Store A')
39
+ expect(storeB.state()).toBeNull()
40
+ })
41
+
42
+ it('close() resets state independently per store', () => {
43
+ const storeA = createScratchpadStore()
44
+ const storeB = createScratchpadStore()
45
+
46
+ storeA.dispatch(createEvent('a', 'Store A'))
47
+ storeB.dispatch(createEvent('b', 'Store B'))
48
+
49
+ storeA.close()
50
+
51
+ expect(storeA.state()).toBeNull()
52
+ expect(storeB.state()?.id).toBe('b')
53
+ })
54
+
55
+ it('pinned flag is per-store', () => {
56
+ const storeA = createScratchpadStore()
57
+ const storeB = createScratchpadStore()
58
+
59
+ storeA.dispatch({ ...createEvent('a', 'A'), pinned: true })
60
+ storeB.dispatch({ ...createEvent('b', 'B'), pinned: false })
61
+
62
+ expect(storeA.pinned()).toBe(true)
63
+ expect(storeB.pinned()).toBe(false)
64
+ })
65
+
66
+ it('ScratchpadStoreProvider without store prop creates a fresh store', () => {
67
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
68
+
69
+ const Child = () => {
70
+ capturedState = useScratchpadState()
71
+ return <div>child</div>
72
+ }
73
+
74
+ render(() => (
75
+ <ScratchpadStoreProvider>
76
+ <Child />
77
+ </ScratchpadStoreProvider>
78
+ ))
79
+
80
+ expect(capturedState).not.toBeNull()
81
+ expect(capturedState!.state()).toBeNull()
82
+ expect(capturedState!.pinned()).toBe(false)
83
+ })
84
+
85
+ it('ScratchpadStoreProvider with explicit store prop binds children to it', () => {
86
+ const scoped = createScratchpadStore()
87
+ scoped.dispatch(createEvent('scoped', 'Scoped title'))
88
+
89
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
90
+ const Child = () => {
91
+ capturedState = useScratchpadState()
92
+ return <div>child</div>
93
+ }
94
+
95
+ render(() => (
96
+ <ScratchpadStoreProvider store={scoped}>
97
+ <Child />
98
+ </ScratchpadStoreProvider>
99
+ ))
100
+
101
+ expect(capturedState!.state()?.id).toBe('scoped')
102
+ expect(capturedState!.state()?.title).toBe('Scoped title')
103
+ })
104
+
105
+ it('useScratchpadState outside provider falls back to module singleton', () => {
106
+ // Reset singleton first by writing a unique id, then verifying dispatch lands on it
107
+ dispatchScratchpad(createEvent('singleton-test', 'Singleton'))
108
+
109
+ let capturedState: ReturnType<typeof useScratchpadState> | null = null
110
+ const Child = () => {
111
+ capturedState = useScratchpadState()
112
+ return <div>child</div>
113
+ }
114
+
115
+ render(() => <Child />)
116
+
117
+ expect(capturedState!.state()?.id).toBe('singleton-test')
118
+ // Cleanup: close singleton for test isolation
119
+ capturedState!.close()
120
+ })
121
+
122
+ it('dispatchScratchpad (module fn) only writes to singleton, not scoped stores', () => {
123
+ const scoped = createScratchpadStore()
124
+ dispatchScratchpad(createEvent('global', 'Global'))
125
+
126
+ // scoped store is still empty
127
+ expect(scoped.state()).toBeNull()
128
+
129
+ // and the singleton carries the dispatched event
130
+ let singletonSnap: ReturnType<typeof useScratchpadState> | null = null
131
+ const Child = () => {
132
+ singletonSnap = useScratchpadState()
133
+ return <div>child</div>
134
+ }
135
+ render(() => <Child />)
136
+ expect(singletonSnap!.state()?.id).toBe('global')
137
+ // Cleanup
138
+ singletonSnap!.close()
139
+ })
140
+ })
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Scratchpad Store — reactive state for HITL scratchpad
3
+ *
4
+ * @experimental
5
+ *
6
+ * **v5.2.0 :** the store is now a factory (`createScratchpadStore()`) with a
7
+ * module-level singleton kept as default. Two consumption modes :
8
+ *
9
+ * 1. **Singleton mode (default, zero-config)** — `dispatchScratchpad(event)` +
10
+ * `useScratchpadState()` read/write the module singleton. This is the v4.x
11
+ * path and keeps working unchanged.
12
+ *
13
+ * 2. **Multi-instance mode** — wrap a subtree in `<ScratchpadStoreProvider>`
14
+ * (it creates a scoped `ScratchpadStoreHandle` internally, or use your own
15
+ * via the `store` prop). `useScratchpadState()` auto-detects the context
16
+ * and reads from it; `ScratchpadPanel` mounted inside the provider reads
17
+ * the scoped store. Non-reactive callers (SSE parsers) should pass the
18
+ * handle explicitly — do NOT try to reach context from a non-reactive
19
+ * scope.
20
+ */
21
+
22
+ import { createContext, useContext, type ParentComponent, type JSX } from 'solid-js'
23
+ import { createStore, produce } from 'solid-js/store'
24
+ import type { ScratchpadState, ScratchpadEvent, ScratchpadSection } from '../types/chat-bus'
25
+
26
+ // ─── Handle shape ─────────────────────────────────────────────
27
+
28
+ export interface ScratchpadStoreHandle {
29
+ /** Mutate the store from an SSE/parser callback. */
30
+ dispatch: (event: ScratchpadEvent) => void
31
+ /** Reactive accessor for the current scratchpad state (null when closed). */
32
+ state: () => ScratchpadState | null
33
+ /** Reactive accessor for the pinned flag. */
34
+ pinned: () => boolean
35
+ /** Close the scratchpad (equivalent to dispatching an action='close'). */
36
+ close: () => void
37
+ }
38
+
39
+ // ─── Factory ──────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Create an isolated scratchpad store instance.
43
+ *
44
+ * Use this when you need two or more scratchpads live at the same time
45
+ * (e.g. chat scratchpad + admin dashboard scratchpad). Pair with
46
+ * `<ScratchpadStoreProvider store={...}>` to scope a SolidJS subtree.
47
+ *
48
+ * @experimental
49
+ * @since v5.2.0
50
+ */
51
+ export function createScratchpadStore(): ScratchpadStoreHandle {
52
+ const [scratchpadStore, setScratchpadStore] = createStore<{
53
+ current: ScratchpadState | null
54
+ pinned: boolean
55
+ }>({ current: null, pinned: false })
56
+
57
+ const dispatch = (event: ScratchpadEvent): void => {
58
+ if (event.action === 'create') {
59
+ console.info(
60
+ `%c[MCP-UI] dispatchScratchpad%c create id=${event.id} sections=${event.sections?.length || 0} status=${event.status || 'loading'}${event.pinned ? ' pinned' : ''}`,
61
+ 'color: #10b981; font-weight: bold',
62
+ 'color: inherit'
63
+ )
64
+ setScratchpadStore({
65
+ current: {
66
+ id: event.id,
67
+ title: event.title || '',
68
+ sections: event.sections || [],
69
+ filters: event.filters || {},
70
+ preview: event.preview,
71
+ agentMessages: event.agentMessages || [],
72
+ status: event.status || 'loading',
73
+ previewEndpoint: (event as any).previewEndpoint,
74
+ previewDebounce: (event as any).previewDebounce,
75
+ previewMethod: (event as any).previewMethod,
76
+ previewHeaders: (event as any).previewHeaders,
77
+ turn: (event as any).turn,
78
+ totalTurns: (event as any).totalTurns,
79
+ turnHistory: (event as any).turnHistory,
80
+ },
81
+ pinned: event.pinned || false,
82
+ })
83
+ } else if (event.action === 'update') {
84
+ console.info(
85
+ `%c[MCP-UI] dispatchScratchpad%c update id=${event.id} sectionMode=${event.sectionMode || 'replace'} sections=${event.sections?.length || 0} status=${event.status || '-'}`,
86
+ 'color: #3b82f6; font-weight: bold',
87
+ 'color: inherit'
88
+ )
89
+ setScratchpadStore(
90
+ produce((s) => {
91
+ if (!s.current || s.current.id !== event.id) {
92
+ console.warn(
93
+ `[MCP-UI] dispatchScratchpad: update for id=${event.id} but current is ${s.current?.id || 'null'}. Ignoring.`
94
+ )
95
+ return
96
+ }
97
+
98
+ if (event.sections) {
99
+ const mode = event.sectionMode || 'replace'
100
+ if (mode === 'replace') {
101
+ s.current.sections = event.sections
102
+ } else if (mode === 'append') {
103
+ s.current.sections = [...s.current.sections, ...event.sections]
104
+ } else if (mode === 'upsert') {
105
+ let matchCount = 0
106
+ for (const incoming of event.sections) {
107
+ const idx = s.current.sections.findIndex(
108
+ (sec: ScratchpadSection) => sec.id === incoming.id
109
+ )
110
+ if (idx >= 0) {
111
+ s.current.sections[idx] = incoming
112
+ matchCount++
113
+ } else {
114
+ s.current.sections.push(incoming)
115
+ }
116
+ }
117
+ if (matchCount === 0 && event.sections.length > 0) {
118
+ console.warn(
119
+ `[MCP-UI] dispatchScratchpad: sectionMode='upsert' but no IDs matched. ` +
120
+ `Incoming: [${event.sections.map((s: ScratchpadSection) => s.id).join(', ')}] ` +
121
+ `Existing: [${s.current.sections.map((s: ScratchpadSection) => s.id).join(', ')}]. All appended.`
122
+ )
123
+ }
124
+ }
125
+ }
126
+ if (event.agentMessages) s.current.agentMessages = event.agentMessages
127
+ if (event.status) s.current.status = event.status
128
+ if (event.filters) s.current.filters = event.filters
129
+ if (event.preview) s.current.preview = event.preview
130
+ if (event.pinned != null) s.pinned = event.pinned
131
+ if ((event as any).turnHistory) s.current.turnHistory = (event as any).turnHistory
132
+ if ((event as any).turn != null) s.current.turn = (event as any).turn
133
+ })
134
+ )
135
+ } else if (event.action === 'close') {
136
+ console.info(
137
+ `%c[MCP-UI] dispatchScratchpad%c close id=${event.id}`,
138
+ 'color: #6b7280; font-weight: bold',
139
+ 'color: inherit'
140
+ )
141
+ setScratchpadStore({ current: null, pinned: false })
142
+ }
143
+ }
144
+
145
+ return {
146
+ dispatch,
147
+ state: () => scratchpadStore.current,
148
+ pinned: () => scratchpadStore.pinned,
149
+ close: () => setScratchpadStore({ current: null, pinned: false }),
150
+ }
151
+ }
152
+
153
+ // ─── Module-level singleton (v4.x-compatible default) ─────────
154
+
155
+ const defaultStore: ScratchpadStoreHandle = createScratchpadStore()
156
+
157
+ /**
158
+ * Function for the PARSER/STORE — mutates the **module-level singleton**
159
+ * scratchpad state. Use this when you only need one scratchpad at a time
160
+ * (single-instance consumer, the v4.x pattern).
161
+ *
162
+ * For multi-instance scenarios, prefer `createScratchpadStore()` and pass the
163
+ * handle around explicitly.
164
+ *
165
+ * @example
166
+ * // In your SSE parser callback — ONE LINE
167
+ * onScratchpad: (data) => dispatchScratchpad(data as ScratchpadEvent)
168
+ */
169
+ export function dispatchScratchpad(event: ScratchpadEvent): void {
170
+ defaultStore.dispatch(event)
171
+ }
172
+
173
+ // ─── Context (v5.2.0) ─────────────────────────────────────────
174
+
175
+ /**
176
+ * Context for a scoped scratchpad store. Populated by
177
+ * `<ScratchpadStoreProvider>`. Read by `useScratchpadState()` with automatic
178
+ * fallback to the module-level singleton when the context is absent.
179
+ *
180
+ * @experimental
181
+ * @since v5.2.0
182
+ */
183
+ export const ScratchpadStoreContext = createContext<ScratchpadStoreHandle | undefined>(undefined)
184
+
185
+ /**
186
+ * Provide a scoped `ScratchpadStoreHandle` to a SolidJS subtree. Children
187
+ * reading via `useScratchpadState()` or rendering a `<ScratchpadPanel>` will
188
+ * bind to this store instead of the module singleton.
189
+ *
190
+ * If no `store` prop is passed, a fresh store is created for the provider's
191
+ * lifetime. Pass `store` explicitly when you need the handle outside the
192
+ * tree (e.g. in an SSE parser that lives at the app root).
193
+ *
194
+ * @experimental
195
+ * @since v5.2.0
196
+ *
197
+ * @example
198
+ * const chatStore = createScratchpadStore()
199
+ * const adminStore = createScratchpadStore()
200
+ *
201
+ * <ScratchpadStoreProvider store={chatStore}>
202
+ * <ChatInterface />
203
+ * </ScratchpadStoreProvider>
204
+ * <ScratchpadStoreProvider store={adminStore}>
205
+ * <AdminDashboard />
206
+ * </ScratchpadStoreProvider>
207
+ */
208
+ export const ScratchpadStoreProvider: ParentComponent<{
209
+ store?: ScratchpadStoreHandle
210
+ }> = (props): JSX.Element => {
211
+ const store = props.store ?? createScratchpadStore()
212
+ return (
213
+ <ScratchpadStoreContext.Provider value={store}>{props.children}</ScratchpadStoreContext.Provider>
214
+ )
215
+ }
216
+
217
+ // ─── Reactive hook (context-aware) ────────────────────────────
218
+
219
+ /**
220
+ * Hook for the COMPONENT — reads the scratchpad state reactively.
221
+ *
222
+ * **v5.2.0 :** if called inside a `<ScratchpadStoreProvider>`, reads the
223
+ * scoped handle; otherwise falls back to the module singleton. Old v4.x
224
+ * consumers keep working unchanged.
225
+ *
226
+ * @example
227
+ * const { state, pinned, close } = useScratchpadState()
228
+ * <Show when={state()}>
229
+ * <ScratchpadPanel state={state()!} pinned={pinned()} onClose={close} />
230
+ * </Show>
231
+ */
232
+ export function useScratchpadState(): {
233
+ state: () => ScratchpadState | null
234
+ pinned: () => boolean
235
+ close: () => void
236
+ } {
237
+ const scoped = useContext(ScratchpadStoreContext)
238
+ const handle = scoped ?? defaultStore
239
+ return {
240
+ state: handle.state,
241
+ pinned: handle.pinned,
242
+ close: handle.close,
243
+ }
244
+ }
@@ -6,6 +6,7 @@
6
6
  * See CHANGELOG for breaking changes on experimental types.
7
7
  */
8
8
 
9
+ import type { JSX } from 'solid-js'
9
10
  import type { UIComponent, UILayout } from './index'
10
11
 
11
12
  // ─── Event Base ──────────────────────────────────────────────
@@ -52,6 +53,7 @@ export interface ChatEvents {
52
53
  // --- Interactions ---
53
54
  onChatPromptResponse: (event: ChatEventBase & { response: ChatPromptResponse }) => void
54
55
  onClarificationNeeded: (event: ChatEventBase & { clarification: ClarificationEvent }) => void
56
+ onElicitation: (event: ChatEventBase & { elicitation: ElicitationEvent }) => void
55
57
 
56
58
  // --- Agentic (handled by app, not MCP-UI) ---
57
59
  onAgentSwitch: (event: ChatEventBase & { agent: AgentContext }) => void
@@ -97,15 +99,44 @@ export interface ChatCommands {
97
99
  /**
98
100
  * Show a ChatPrompt (choice, confirm, form) above the input (C4).
99
101
  *
100
- * **Known limitation (v4.3.9):** Not re-entrant. If called while another
101
- * prompt is already active, the previous prompt's Promise will never resolve
102
- * (memory leak). Host apps must queue prompts or dismiss the previous one
103
- * manually before showing a new one. Fix planned for v4.4.0 (auto-reject
104
- * previous prompt or FIFO queue).
102
+ * **No default handler in v5.0.0 / v5.1.0.** `showChatPrompt` is a command
103
+ * *name*, not a default implementation mcp-ui ships `ChatPrompt` (the
104
+ * presentation component) and the bus (event/command plumbing), but the
105
+ * handler that threads a Promise resolver through the SolidJS lifecycle is
106
+ * the consumer's responsibility. Every host app calls
107
+ * `bus.commands.handle('showChatPrompt', (config, signal?) => { ... })`.
105
108
  *
106
- * **AbortSignal limitation (v4.3.9):** The `signal` argument is currently
107
- * unused — `ChatPrompt` does not listen to aborts. Host apps must wire
108
- * abort Promise rejection themselves. Fix planned for v4.4.0.
109
+ * ### Implementer contract
110
+ *
111
+ * A conforming handler MUST:
112
+ *
113
+ * 1. Return a `Promise<ChatPromptResponse>`.
114
+ * 2. Resolve the Promise from the `ChatPrompt` component's `onSubmit`
115
+ * (explicit answer) or from `onDismiss` (dismissed flag true).
116
+ * 3. If a `signal` is provided:
117
+ * - If `signal.aborted` is already `true`, reject with
118
+ * `new DOMException('Prompt aborted', 'AbortError')` synchronously
119
+ * (or via `Promise.reject`) and do NOT show the UI.
120
+ * - Otherwise, register `signal.addEventListener('abort', () =>
121
+ * reject(new DOMException('Prompt aborted', 'AbortError')))` and
122
+ * clean up the listener on resolve/dismiss.
123
+ * 4. Enforce re-entrance policy — if a previous prompt is still active
124
+ * when a new one arrives, the recommended behavior is auto-reject the
125
+ * previous Promise with a custom error (e.g. `PromptReplacedError`).
126
+ * Alternatives: FIFO queue, or throw synchronously.
127
+ *
128
+ * The `DOMException('AbortError')` shape is the Web Platform convention
129
+ * (matches `fetch()`, `Response.body.cancel()`, `WritableStream.abort()`).
130
+ * Consumers branching on the error can do
131
+ * `catch (err) { if (err.name === 'AbortError') return; throw err }`.
132
+ *
133
+ * ### Planned primitive (v5.2.0)
134
+ *
135
+ * A `createChatPromptController(setActivePrompt)` helper will centralise
136
+ * the resolver lifecycle + abort + re-entrance logic once, so consumers
137
+ * can write `bus.commands.handle('showChatPrompt', ctrl.handle)` instead
138
+ * of threading a `let chatPromptResolver` closure by hand. Design doc:
139
+ * `docs/2026/r&d/mcpui-v5.1.0-consensus.md`.
109
140
  */
110
141
  showChatPrompt: (config: ChatPromptConfig, signal?: AbortSignal) => Promise<ChatPromptResponse>
111
142
  /** Dismiss the active ChatPrompt */
@@ -208,21 +239,95 @@ export interface ChatPromptConfig {
208
239
  config: ChoicePromptConfig | ConfirmPromptConfig | FormPromptConfig
209
240
  }
210
241
 
211
- export interface ChoicePromptConfig {
212
- options: Array<{
213
- value: string
214
- label: string
215
- icon?: string
216
- description?: string
217
- /**
218
- * Free-form metadata (confidence, source, tags, ...).
219
- * Opaque to default renderer — use a custom ChoiceBody wrapper to display it.
220
- * Preserved through showChatPrompt → ChatPromptResponse roundtrip.
221
- * @since v4.3.9
222
- */
223
- metadata?: Record<string, unknown>
224
- }>
242
+ /**
243
+ * A single choice option. The generic `TMeta` parameter flows through the
244
+ * whole `ChoicePromptConfig<TMeta>` shape so consumers can strongly-type
245
+ * their metadata in `optionRenderer` without casting.
246
+ *
247
+ * @since v4.3.9 (metadata), v5.1.0 (generic TMeta + optionRenderer typing)
248
+ */
249
+ export interface ChoiceOption<TMeta = Record<string, unknown>> {
250
+ value: string
251
+ label: string
252
+ icon?: string
253
+ description?: string
254
+ /**
255
+ * Free-form metadata (confidence, source, tags, ...).
256
+ * Opaque to the default renderer — use `optionRenderer` to display it.
257
+ * Preserved through `showChatPrompt → ChatPromptResponse` roundtrip.
258
+ * @since v4.3.9
259
+ */
260
+ metadata?: TMeta
261
+ }
262
+
263
+ export interface ChoicePromptConfig<TMeta = Record<string, unknown>> {
264
+ options: Array<ChoiceOption<TMeta>>
225
265
  layout?: 'horizontal' | 'vertical' | 'grid'
266
+ /**
267
+ * Optional render prop for custom option bodies (badges, confidence
268
+ * indicators, rich layouts). Replaces the default `label + icon +
269
+ * description` body. mcp-ui still wraps the returned JSX in a `<button>`
270
+ * with the `onClick` handler, keyboard support, and focus styles — only
271
+ * the *content* of the button is yours.
272
+ *
273
+ * @param option The full `ChoiceOption` including strongly-typed `metadata`.
274
+ * @param index Zero-based position in the `options` array.
275
+ *
276
+ * @example
277
+ * ```tsx
278
+ * interface ConfBadgeMeta { confidence: number; source: string }
279
+ *
280
+ * bus.commands.exec('showChatPrompt', {
281
+ * type: 'choice',
282
+ * title: 'Pick an intent',
283
+ * config: {
284
+ * layout: 'vertical',
285
+ * options: [
286
+ * { value: 'a', label: 'Immobilier', metadata: { confidence: 0.9, source: 'llm' } },
287
+ * { value: 'b', label: 'Santé', metadata: { confidence: 0.4, source: 'llm' } },
288
+ * ],
289
+ * optionRenderer: (opt: ChoiceOption<ConfBadgeMeta>) => (
290
+ * <div>
291
+ * {opt.label}
292
+ * <span class="ml-2 text-xs">
293
+ * ({Math.round((opt.metadata?.confidence ?? 0) * 100)}%)
294
+ * </span>
295
+ * </div>
296
+ * ),
297
+ * },
298
+ * } as ChatPromptConfig)
299
+ * ```
300
+ *
301
+ * ### ⚠️ Accessibility
302
+ * Do NOT return `<button>`, `<a href>`, or other interactive elements from
303
+ * `optionRenderer`. mcp-ui already wraps the content in a `<button>`, and
304
+ * nested interactive elements break screen-reader semantics, keyboard
305
+ * focus order, and click-through behaviour.
306
+ *
307
+ * ### ⚠️ Stale closures
308
+ * `optionRenderer` is called once per option per render. If you capture
309
+ * SolidJS signals inside the closure, wrap the access in a thunk so the
310
+ * framework tracks the dependency correctly. Don't destructure signal
311
+ * values into locals outside reactive scopes.
312
+ *
313
+ * @since v5.1.0
314
+ */
315
+ optionRenderer?: (option: ChoiceOption<TMeta>, index: number) => JSX.Element
316
+ /**
317
+ * Custom Tailwind classes appended to each option button (after mcp-ui's
318
+ * defaults). Escape hatch for colour/border/radius tweaks that don't
319
+ * warrant a full `optionRenderer`.
320
+ *
321
+ * @since v5.1.0
322
+ */
323
+ buttonClass?: string
324
+ /**
325
+ * Custom Tailwind classes appended to the options container (the
326
+ * flex/grid wrapper that lays out the buttons).
327
+ *
328
+ * @since v5.1.0
329
+ */
330
+ containerClass?: string
226
331
  }
227
332
 
228
333
  export interface ConfirmPromptConfig {
@@ -464,6 +569,45 @@ export interface ToolCallEvent {
464
569
  duration_ms?: number
465
570
  }
466
571
 
572
+ /**
573
+ * MCP `elicitation/create` request payload — server asks the client to
574
+ * collect input from the user according to a JSON Schema.
575
+ *
576
+ * Derived from MCP spec 2025-06-18. See
577
+ * `elicitationToPromptConfig()` in `services/chat-bus.ts` for the
578
+ * helper that converts this to a `ChatPromptConfig`.
579
+ *
580
+ * @experimental
581
+ * @since v5.2.0
582
+ */
583
+ export interface ElicitationEvent {
584
+ /** Question / instruction to present to the user. */
585
+ message: string
586
+ /** JSON Schema describing the expected response shape. Object with primitive properties only. */
587
+ requestedSchema: ElicitationRequestedSchema
588
+ }
589
+
590
+ export interface ElicitationRequestedSchema {
591
+ type: 'object'
592
+ properties: Record<string, ElicitationPropertySchema>
593
+ required?: string[]
594
+ }
595
+
596
+ export interface ElicitationPropertySchema {
597
+ type: 'string' | 'number' | 'integer' | 'boolean'
598
+ title?: string
599
+ description?: string
600
+ /** Enum of allowed values (strings or numbers). */
601
+ enum?: Array<string | number>
602
+ /** Parallel array with display labels for each enum entry. */
603
+ enumNames?: string[]
604
+ default?: unknown
605
+ minimum?: number
606
+ maximum?: number
607
+ /** String format hint — date, date-time, email, uri. */
608
+ format?: 'date' | 'date-time' | 'email' | 'uri'
609
+ }
610
+
467
611
  export interface ClarificationEvent {
468
612
  /** The question to ask the user */
469
613
  question: string