@seed-ship/mcp-ui-solid 5.1.0 → 5.3.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 (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +64 -13
  3. package/dist/components/ElicitationForm.cjs +51 -0
  4. package/dist/components/ElicitationForm.cjs.map +1 -0
  5. package/dist/components/ElicitationForm.d.ts +68 -0
  6. package/dist/components/ElicitationForm.d.ts.map +1 -0
  7. package/dist/components/ElicitationForm.js +51 -0
  8. package/dist/components/ElicitationForm.js.map +1 -0
  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/components/index.d.ts +2 -0
  16. package/dist/components/index.d.ts.map +1 -1
  17. package/dist/components.cjs +2 -0
  18. package/dist/components.cjs.map +1 -1
  19. package/dist/components.d.cts +2 -0
  20. package/dist/components.d.ts +2 -0
  21. package/dist/components.js +2 -0
  22. package/dist/components.js.map +1 -1
  23. package/dist/index.cjs +17 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +12 -2
  26. package/dist/index.d.ts +12 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +19 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/services/chat-bus.cjs +71 -0
  31. package/dist/services/chat-bus.cjs.map +1 -1
  32. package/dist/services/chat-bus.d.ts +31 -1
  33. package/dist/services/chat-bus.d.ts.map +1 -1
  34. package/dist/services/chat-bus.js +71 -0
  35. package/dist/services/chat-bus.js.map +1 -1
  36. package/dist/services/chat-prompt-controller.cjs +83 -0
  37. package/dist/services/chat-prompt-controller.cjs.map +1 -0
  38. package/dist/services/chat-prompt-controller.d.ts +93 -0
  39. package/dist/services/chat-prompt-controller.d.ts.map +1 -0
  40. package/dist/services/chat-prompt-controller.js +83 -0
  41. package/dist/services/chat-prompt-controller.js.map +1 -0
  42. package/dist/stores/scratchpad-store.cjs +105 -77
  43. package/dist/stores/scratchpad-store.cjs.map +1 -1
  44. package/dist/stores/scratchpad-store.d.ts +88 -19
  45. package/dist/stores/scratchpad-store.d.ts.map +1 -1
  46. package/dist/stores/scratchpad-store.js +105 -77
  47. package/dist/stores/scratchpad-store.js.map +1 -1
  48. package/dist/stores/server-capabilities-store.cjs +61 -0
  49. package/dist/stores/server-capabilities-store.cjs.map +1 -0
  50. package/dist/stores/server-capabilities-store.d.ts +172 -0
  51. package/dist/stores/server-capabilities-store.d.ts.map +1 -0
  52. package/dist/stores/server-capabilities-store.js +61 -0
  53. package/dist/stores/server-capabilities-store.js.map +1 -0
  54. package/dist/types/chat-bus.d.ts +39 -0
  55. package/dist/types/chat-bus.d.ts.map +1 -1
  56. package/docs/recipes/elicitation-pseudo-spec-adapter.md +171 -0
  57. package/docs/recipes/feedback-inline-wiring.md +142 -0
  58. package/package.json +1 -1
  59. package/src/components/ElicitationForm.test.tsx +197 -0
  60. package/src/components/ElicitationForm.tsx +126 -0
  61. package/src/components/FeedbackInline.test.tsx +117 -0
  62. package/src/components/FeedbackInline.tsx +143 -0
  63. package/src/components/index.ts +4 -0
  64. package/src/index.ts +39 -1
  65. package/src/services/chat-bus.test.ts +154 -2
  66. package/src/services/chat-bus.ts +115 -0
  67. package/src/services/chat-prompt-controller.test.ts +144 -0
  68. package/src/services/chat-prompt-controller.ts +214 -0
  69. package/src/stores/scratchpad-store.test.tsx +140 -0
  70. package/src/stores/scratchpad-store.tsx +244 -0
  71. package/src/stores/server-capabilities-store.test.tsx +206 -0
  72. package/src/stores/server-capabilities-store.tsx +215 -0
  73. package/src/types/chat-bus.ts +40 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/src/stores/scratchpad-store.ts +0 -126
@@ -0,0 +1,143 @@
1
+ /**
2
+ * FeedbackInline — per-message inline feedback (thumbs up/down)
3
+ *
4
+ * @experimental
5
+ * @since v5.2.0
6
+ *
7
+ * A small, non-blocking per-message feedback primitive. Sits next to an
8
+ * assistant message, captures a rating, calls back to the consumer for
9
+ * persistence. Best-effort by design — no retry UX, no revision UX.
10
+ *
11
+ * ## When to use vs other feedback primitives
12
+ *
13
+ * - **`FeedbackInline`** (this) → per-message thumb-up/down, non-blocking,
14
+ * many can coexist.
15
+ * - **`ChatPrompt` (type=choice)** → modal, one-at-a-time above the input,
16
+ * used when the agent needs a blocking answer.
17
+ * - **`ScratchpadPanel` feedback section** → structured feedback bound to a
18
+ * scratchpad turn, panel-side.
19
+ *
20
+ * ## Persistence is the consumer's job
21
+ *
22
+ * The component flips to "submitted" state *optimistically* on click and
23
+ * calls `onSubmit(rating, context)`. Network failures do not revert the UI —
24
+ * feedback is best-effort. If you need stricter semantics (offline retry,
25
+ * revision, ...) wrap this in your own component.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <FeedbackInline
30
+ * messageHash={msg.hash}
31
+ * context={{ intent: msg.intent, confidenceBand: msg.band }}
32
+ * onSubmit={(rating, ctx) =>
33
+ * fetch('/api/feedback', {
34
+ * method: 'POST',
35
+ * headers: { 'Content-Type': 'application/json' },
36
+ * body: JSON.stringify({ message_hash: msg.hash, rating, ...ctx }),
37
+ * })
38
+ * }
39
+ * />
40
+ * ```
41
+ */
42
+
43
+ import { Component, Show, createSignal } from 'solid-js'
44
+
45
+ export interface FeedbackInlineContext {
46
+ intent?: string
47
+ confidenceBand?: string
48
+ tags?: string[]
49
+ [key: string]: unknown
50
+ }
51
+
52
+ export interface FeedbackInlineProps {
53
+ /** Stable identifier for the message being rated. */
54
+ messageHash?: string
55
+ /**
56
+ * Called on click. Consumer is responsible for persistence (HTTP, store,
57
+ * localStorage). Return value ignored.
58
+ */
59
+ onSubmit: (rating: 'positive' | 'negative', context?: FeedbackInlineContext) => void | Promise<void>
60
+ /** Extra context forwarded to `onSubmit`. */
61
+ context?: FeedbackInlineContext
62
+ /** Ack text shown after positive rating. Default: 'Merci !' */
63
+ positiveAck?: string
64
+ /** Ack text shown after negative rating. Default: "Noté, on s'améliore" */
65
+ negativeAck?: string
66
+ /** Extra Tailwind classes on the container. */
67
+ class?: string
68
+ }
69
+
70
+ /**
71
+ * @experimental
72
+ * Per-message inline feedback (thumbs up/down). Non-blocking.
73
+ */
74
+ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
75
+ const [rating, setRating] = createSignal<'positive' | 'negative' | null>(null)
76
+
77
+ const handle = (value: 'positive' | 'negative') => {
78
+ if (rating() !== null) return // already submitted, final state
79
+ setRating(value)
80
+ try {
81
+ // Fire-and-forget. If the consumer returns a Promise that rejects,
82
+ // swallow it — feedback is best-effort by design.
83
+ const result = props.onSubmit(value, props.context)
84
+ if (result && typeof (result as Promise<void>).catch === 'function') {
85
+ ;(result as Promise<void>).catch(() => {
86
+ /* non-blocking */
87
+ })
88
+ }
89
+ } catch {
90
+ /* non-blocking */
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div class={`flex items-center gap-1 ${props.class ?? ''}`.trim()}>
96
+ <Show
97
+ when={rating() === null}
98
+ fallback={
99
+ <span class="text-[11px] text-deposium-slate-500">
100
+ {rating() === 'positive'
101
+ ? (props.positiveAck ?? 'Merci !')
102
+ : (props.negativeAck ?? "Noté, on s'améliore")}
103
+ </span>
104
+ }
105
+ >
106
+ <button
107
+ type="button"
108
+ onClick={() => handle('positive')}
109
+ class="p-1 rounded hover:bg-green-500/10 text-deposium-slate-500 hover:text-green-500 transition-colors"
110
+ title="Utile"
111
+ aria-label="Mark response as useful"
112
+ data-feedback-inline-rating="positive"
113
+ >
114
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
115
+ <path
116
+ stroke-linecap="round"
117
+ stroke-linejoin="round"
118
+ stroke-width="2"
119
+ d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14z M3 15v7"
120
+ />
121
+ </svg>
122
+ </button>
123
+ <button
124
+ type="button"
125
+ onClick={() => handle('negative')}
126
+ class="p-1 rounded hover:bg-red-500/10 text-deposium-slate-500 hover:text-red-500 transition-colors"
127
+ title="Pas utile"
128
+ aria-label="Mark response as not useful"
129
+ data-feedback-inline-rating="negative"
130
+ >
131
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
132
+ <path
133
+ stroke-linecap="round"
134
+ stroke-linejoin="round"
135
+ stroke-width="2"
136
+ d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3H10z M21 4v7"
137
+ />
138
+ </svg>
139
+ </button>
140
+ </Show>
141
+ </div>
142
+ )
143
+ }
@@ -88,5 +88,9 @@ export type { DataPreviewSectionProps } from './DataPreviewSection'
88
88
  export { RenderContext, RenderProvider, useRenderContext } from './RenderContext'
89
89
  export type { RenderContextValue, RenderComponentFn } from './RenderContext'
90
90
 
91
+ // MCP elicitation (v5.3.0)
92
+ export { ElicitationForm } from './ElicitationForm'
93
+ export type { ElicitationFormProps } from './ElicitationForm'
94
+
91
95
  // Default exports for lazy loading compatibility
92
96
  export { UIResourceRenderer as default } from './UIResourceRenderer'
package/src/index.ts CHANGED
@@ -37,12 +37,36 @@ export { ResizeHandle } from './components/ResizeHandle'
37
37
  export { EditableUIResourceRenderer } from './components/EditableUIResourceRenderer'
38
38
  export { ExpandableWrapper, useExpanded } from './components/ExpandableWrapper'
39
39
  export { ComponentToolbar } from './components/ComponentToolbar'
40
+ export { FeedbackInline } from './components/FeedbackInline'
41
+ export type { FeedbackInlineProps, FeedbackInlineContext } from './components/FeedbackInline'
40
42
 
41
43
  // Chat Bus (v2.4.0 — @experimental)
42
44
  export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
43
45
  export { ChatPrompt } from './components/ChatPrompt'
46
+ export { ElicitationForm } from './components/ElicitationForm'
44
47
  export { ScratchpadPanel } from './components/ScratchpadPanel'
45
- export { dispatchScratchpad, useScratchpadState } from './stores/scratchpad-store'
48
+ export {
49
+ dispatchScratchpad,
50
+ useScratchpadState,
51
+ createScratchpadStore,
52
+ ScratchpadStoreContext,
53
+ ScratchpadStoreProvider,
54
+ } from './stores/scratchpad-store'
55
+ export type { ScratchpadStoreHandle } from './stores/scratchpad-store'
56
+
57
+ // Server Capabilities (v5.3.0)
58
+ export {
59
+ setServerCapabilities,
60
+ useServerCapabilities,
61
+ createServerCapabilitiesStore,
62
+ ServerCapabilitiesContext,
63
+ ServerCapabilitiesProvider,
64
+ } from './stores/server-capabilities-store'
65
+ export type {
66
+ ServerCapabilities,
67
+ ServerInitializeInfo,
68
+ ServerCapabilitiesStoreHandle,
69
+ } from './stores/server-capabilities-store'
46
70
 
47
71
  // Data Verification Components (v3.1.0)
48
72
  export { VerifiedText } from './components/VerifiedText'
@@ -71,6 +95,7 @@ export type { EditableUIResourceRendererProps } from './components/EditableUIRes
71
95
  export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
72
96
  export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
73
97
  export type { ChatPromptProps } from './components/ChatPrompt'
98
+ export type { ElicitationFormProps } from './components/ElicitationForm'
74
99
  export type { ScratchpadPanelProps } from './components/ScratchpadPanel'
75
100
  export type { VerifiedTextProps } from './components/VerifiedText'
76
101
  export type { DataPreviewSectionProps } from './components/DataPreviewSection'
@@ -248,6 +273,16 @@ export {
248
273
  // Clarification → Prompt helper (v4.3.9)
249
274
  export { clarificationToPromptConfig } from './services/chat-bus'
250
275
 
276
+ // Elicitation → Prompt helper (v5.2.0)
277
+ export { elicitationToPromptConfig } from './services/chat-bus'
278
+
279
+ // Chat prompt controller (v5.2.0)
280
+ export {
281
+ createChatPromptController,
282
+ PromptReplacedError,
283
+ } from './services/chat-prompt-controller'
284
+ export type { ChatPromptController } from './services/chat-prompt-controller'
285
+
251
286
  // Testing utilities (v4.3.9)
252
287
  export { createMockChatBus } from './testing'
253
288
  export type { MockChatBusOptions } from './testing'
@@ -279,6 +314,9 @@ export type {
279
314
  Citation,
280
315
  ToolCallEvent,
281
316
  ClarificationEvent,
317
+ ElicitationEvent,
318
+ ElicitationRequestedSchema,
319
+ ElicitationPropertySchema,
282
320
  // Data Validation types (v3.1.0)
283
321
  DataValidation,
284
322
  LLMNumber,
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6
- import { createEventEmitter, createCommandHandler, createChatBus, clarificationToPromptConfig } from './chat-bus'
7
- import type { ChatEvents, ChatCommands, ClarificationEvent } from '../types/chat-bus'
6
+ import { createEventEmitter, createCommandHandler, createChatBus, clarificationToPromptConfig, elicitationToPromptConfig } from './chat-bus'
7
+ import type { ChatEvents, ChatCommands, ClarificationEvent, ElicitationEvent } from '../types/chat-bus'
8
8
 
9
9
  describe('createEventEmitter', () => {
10
10
  it('emits events to subscribed listeners', () => {
@@ -384,3 +384,155 @@ describe('clarificationToPromptConfig', () => {
384
384
  expect(opts[0]).not.toHaveProperty('metadata')
385
385
  })
386
386
  })
387
+
388
+ describe('elicitationToPromptConfig — v5.2.0', () => {
389
+ it('single boolean property maps to confirm prompt', () => {
390
+ const event: ElicitationEvent = {
391
+ message: 'Proceed with the deployment?',
392
+ requestedSchema: {
393
+ type: 'object',
394
+ properties: { confirmed: { type: 'boolean', description: 'Ship it?' } },
395
+ required: ['confirmed'],
396
+ },
397
+ }
398
+ const config = elicitationToPromptConfig(event)
399
+ expect(config.type).toBe('confirm')
400
+ expect(config.title).toBe('Proceed with the deployment?')
401
+ expect((config.config as any).message).toBe('Ship it?')
402
+ })
403
+
404
+ it('single enum string property (≤4 values) maps to choice prompt', () => {
405
+ const event: ElicitationEvent = {
406
+ message: 'Select severity',
407
+ requestedSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ level: {
411
+ type: 'string',
412
+ enum: ['low', 'medium', 'high'],
413
+ enumNames: ['Low', 'Medium', 'High'],
414
+ },
415
+ },
416
+ },
417
+ }
418
+ const config = elicitationToPromptConfig(event)
419
+ expect(config.type).toBe('choice')
420
+ const opts = (config.config as any).options
421
+ expect(opts).toEqual([
422
+ { value: 'low', label: 'Low' },
423
+ { value: 'medium', label: 'Medium' },
424
+ { value: 'high', label: 'High' },
425
+ ])
426
+ })
427
+
428
+ it('enum with >4 values maps to form with select field (not choice)', () => {
429
+ const event: ElicitationEvent = {
430
+ message: 'Pick a country',
431
+ requestedSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ country: {
435
+ type: 'string',
436
+ enum: ['FR', 'DE', 'IT', 'ES', 'PT'],
437
+ },
438
+ },
439
+ },
440
+ }
441
+ const config = elicitationToPromptConfig(event)
442
+ expect(config.type).toBe('form')
443
+ const fields = (config.config as any).fields
444
+ expect(fields[0].type).toBe('select')
445
+ expect(fields[0].options).toHaveLength(5)
446
+ })
447
+
448
+ it('multi-property object maps to form with one field per property', () => {
449
+ const event: ElicitationEvent = {
450
+ message: 'Fill in contact info',
451
+ requestedSchema: {
452
+ type: 'object',
453
+ properties: {
454
+ name: { type: 'string', title: 'Full name' },
455
+ age: { type: 'integer' },
456
+ newsletter: { type: 'boolean', description: 'Opt-in' },
457
+ },
458
+ required: ['name'],
459
+ },
460
+ }
461
+ const config = elicitationToPromptConfig(event)
462
+ expect(config.type).toBe('form')
463
+ const fields = (config.config as any).fields
464
+ expect(fields).toHaveLength(3)
465
+ expect(fields.map((f: any) => f.name)).toEqual(['name', 'age', 'newsletter'])
466
+ expect(fields[0]).toMatchObject({ type: 'text', label: 'Full name', required: true })
467
+ expect(fields[1]).toMatchObject({ type: 'number', required: false })
468
+ expect(fields[2]).toMatchObject({ type: 'checkbox', helpText: 'Opt-in' })
469
+ })
470
+
471
+ it('string format email maps to email field', () => {
472
+ const event: ElicitationEvent = {
473
+ message: 'Contact',
474
+ requestedSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ email: { type: 'string', format: 'email' },
478
+ reply: { type: 'string' }, // force form (not single-property shortcut)
479
+ },
480
+ },
481
+ }
482
+ const config = elicitationToPromptConfig(event)
483
+ const fields = (config.config as any).fields
484
+ expect(fields[0]).toMatchObject({ name: 'email', type: 'email' })
485
+ })
486
+
487
+ it('string format date / date-time maps to date field', () => {
488
+ const event: ElicitationEvent = {
489
+ message: 'Schedule',
490
+ requestedSchema: {
491
+ type: 'object',
492
+ properties: {
493
+ start: { type: 'string', format: 'date' },
494
+ end: { type: 'string', format: 'date-time' },
495
+ },
496
+ },
497
+ }
498
+ const config = elicitationToPromptConfig(event)
499
+ const fields = (config.config as any).fields
500
+ expect(fields[0].type).toBe('date')
501
+ expect(fields[1].type).toBe('date')
502
+ })
503
+
504
+ it('default value maps to placeholder', () => {
505
+ const event: ElicitationEvent = {
506
+ message: 'Settings',
507
+ requestedSchema: {
508
+ type: 'object',
509
+ properties: {
510
+ retries: { type: 'integer', default: 3 },
511
+ timeout: { type: 'integer' }, // second property to force form
512
+ },
513
+ },
514
+ }
515
+ const config = elicitationToPromptConfig(event)
516
+ const fields = (config.config as any).fields
517
+ expect(fields[0]).toMatchObject({ placeholder: '3' })
518
+ })
519
+
520
+ it('unknown schema type falls through to text with console.warn', () => {
521
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
522
+ const event = {
523
+ message: 'Edge case',
524
+ requestedSchema: {
525
+ type: 'object',
526
+ properties: {
527
+ weird: { type: 'array' }, // not a primitive
528
+ other: { type: 'string' },
529
+ },
530
+ },
531
+ } as unknown as ElicitationEvent
532
+ const config = elicitationToPromptConfig(event)
533
+ const fields = (config.config as any).fields
534
+ expect(fields[0].type).toBe('text')
535
+ expect(warnSpy).toHaveBeenCalled()
536
+ warnSpy.mockRestore()
537
+ })
538
+ })
@@ -14,7 +14,10 @@ import type {
14
14
  EventSubscribeOptions,
15
15
  ScratchpadSection,
16
16
  ClarificationEvent,
17
+ ElicitationEvent,
18
+ ElicitationPropertySchema,
17
19
  ChatPromptConfig,
20
+ FormPromptConfig,
18
21
  } from '../types/chat-bus'
19
22
 
20
23
  // ─── Event Emitter ───────────────────────────────────────────
@@ -245,6 +248,118 @@ export function mergeScratchpadSections(
245
248
  * bus.commands.exec('showChatPrompt', clarificationToPromptConfig(clarification))
246
249
  * })
247
250
  */
251
+ // ─── Elicitation → Prompt Helper (v5.2.0) ───────────────────
252
+
253
+ /**
254
+ * Convert an MCP `elicitation/create` payload into a `ChatPromptConfig`.
255
+ *
256
+ * Mapping rules :
257
+ * - Single `boolean` property → `type: 'confirm'`
258
+ * - Single property with `enum` of ≤4 values → `type: 'choice'` (one option per enum value)
259
+ * - Anything else → `type: 'form'` with one field per schema property
260
+ *
261
+ * JSON Schema primitive types map to mcp-ui form field types :
262
+ *
263
+ * | JSON Schema | mcp-ui FormFieldType |
264
+ * |---|---|
265
+ * | `type: 'string'` | `'text'` |
266
+ * | `type: 'string', format: 'email'` | `'email'` |
267
+ * | `type: 'string', format: 'date'` or `'date-time'` | `'date'` |
268
+ * | `type: 'string', enum: [...]` | `'select'` |
269
+ * | `type: 'number' \| 'integer'` | `'number'` |
270
+ * | `type: 'boolean'` | `'checkbox'` |
271
+ *
272
+ * Unknown shapes fall through to plain text with a `helpText` warning.
273
+ *
274
+ * @experimental
275
+ * @since v5.2.0
276
+ *
277
+ * @example
278
+ * bus.events.on('onElicitation', ({ elicitation }) => {
279
+ * bus.commands.exec('showChatPrompt', elicitationToPromptConfig(elicitation))
280
+ * })
281
+ */
282
+ export function elicitationToPromptConfig(event: ElicitationEvent): ChatPromptConfig {
283
+ const propEntries = Object.entries(event.requestedSchema.properties)
284
+
285
+ // Shortcut 1 : single boolean → confirm
286
+ if (propEntries.length === 1 && propEntries[0][1].type === 'boolean') {
287
+ return {
288
+ type: 'confirm',
289
+ title: event.message,
290
+ config: {
291
+ message: propEntries[0][1].description,
292
+ },
293
+ }
294
+ }
295
+
296
+ // Shortcut 2 : single enum property with ≤4 values → choice
297
+ if (propEntries.length === 1) {
298
+ const [, schema] = propEntries[0]
299
+ if (schema.enum && schema.enum.length > 0 && schema.enum.length <= 4) {
300
+ return {
301
+ type: 'choice',
302
+ title: event.message,
303
+ config: {
304
+ options: schema.enum.map((val, idx) => ({
305
+ value: String(val),
306
+ label: schema.enumNames?.[idx] ?? String(val),
307
+ })),
308
+ layout: 'vertical',
309
+ },
310
+ }
311
+ }
312
+ }
313
+
314
+ // Default : full form
315
+ const required = new Set(event.requestedSchema.required ?? [])
316
+ const fields: FormPromptConfig['fields'] = propEntries.map(([name, schema]) => ({
317
+ name,
318
+ label: schema.title ?? name,
319
+ ...schemaToFieldType(schema),
320
+ required: required.has(name),
321
+ helpText: schema.description,
322
+ ...(schema.default !== undefined ? { placeholder: String(schema.default) } : {}),
323
+ }))
324
+
325
+ return {
326
+ type: 'form',
327
+ title: event.message,
328
+ config: { fields },
329
+ }
330
+ }
331
+
332
+ function schemaToFieldType(
333
+ schema: ElicitationPropertySchema
334
+ ):
335
+ | { type: FormPromptConfig['fields'][number]['type']; options?: Array<{ label: string; value: string }> }
336
+ | { type: FormPromptConfig['fields'][number]['type']; helpText?: string } {
337
+ // Enum → select
338
+ if (schema.enum && schema.enum.length > 0) {
339
+ return {
340
+ type: 'select',
341
+ options: schema.enum.map((val, idx) => ({
342
+ label: schema.enumNames?.[idx] ?? String(val),
343
+ value: String(val),
344
+ })),
345
+ }
346
+ }
347
+
348
+ if (schema.type === 'boolean') return { type: 'checkbox' }
349
+ if (schema.type === 'number' || schema.type === 'integer') return { type: 'number' }
350
+ if (schema.type === 'string') {
351
+ if (schema.format === 'email') return { type: 'email' }
352
+ if (schema.format === 'date' || schema.format === 'date-time') return { type: 'date' }
353
+ return { type: 'text' }
354
+ }
355
+
356
+ // Unknown primitive — fall back to text with a warning
357
+ console.warn(
358
+ `[MCP-UI] elicitationToPromptConfig: unsupported schema type "${(schema as { type?: string }).type}", falling back to text.`
359
+ )
360
+ return { type: 'text' }
361
+ }
362
+
248
363
  export function clarificationToPromptConfig(
249
364
  event: ClarificationEvent
250
365
  ): ChatPromptConfig {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tests for createChatPromptController — v5.2.0
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import { createRoot } from 'solid-js'
7
+ import {
8
+ createChatPromptController,
9
+ PromptReplacedError,
10
+ } from './chat-prompt-controller'
11
+ import type { ChatPromptConfig, ChatPromptResponse } from '../types/chat-bus'
12
+
13
+ const choiceConfig = (title = 'Pick one'): ChatPromptConfig => ({
14
+ type: 'choice',
15
+ title,
16
+ config: { options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] },
17
+ })
18
+
19
+ const confirmConfig = (title = 'Confirm?'): ChatPromptConfig => ({
20
+ type: 'confirm',
21
+ title,
22
+ config: { message: 'Sure?' },
23
+ })
24
+
25
+ const choiceResponse = (value: string): ChatPromptResponse => ({
26
+ type: 'choice',
27
+ value,
28
+ label: value,
29
+ })
30
+
31
+ describe('createChatPromptController — v5.2.0', () => {
32
+ it('sequential prompts both resolve', async () => {
33
+ await createRoot(async (dispose) => {
34
+ const ctrl = createChatPromptController()
35
+
36
+ const p1 = ctrl.handle(choiceConfig('First'))
37
+ expect(ctrl.activePrompt()?.title).toBe('First')
38
+ ctrl.resolveActive(choiceResponse('a'))
39
+ expect(ctrl.activePrompt()).toBeNull()
40
+ const r1 = await p1
41
+ expect(r1.value).toBe('a')
42
+
43
+ const p2 = ctrl.handle(choiceConfig('Second'))
44
+ expect(ctrl.activePrompt()?.title).toBe('Second')
45
+ ctrl.resolveActive(choiceResponse('b'))
46
+ const r2 = await p2
47
+ expect(r2.value).toBe('b')
48
+
49
+ dispose()
50
+ })
51
+ })
52
+
53
+ it('re-entrant call rejects previous Promise with PromptReplacedError', async () => {
54
+ await createRoot(async (dispose) => {
55
+ const ctrl = createChatPromptController()
56
+
57
+ const p1 = ctrl.handle(choiceConfig('First'))
58
+ // Don't resolve — fire a second one
59
+ const p2 = ctrl.handle(choiceConfig('Second'))
60
+
61
+ await expect(p1).rejects.toBeInstanceOf(PromptReplacedError)
62
+
63
+ // The second prompt is now active and can still resolve
64
+ expect(ctrl.activePrompt()?.title).toBe('Second')
65
+ ctrl.resolveActive(choiceResponse('b'))
66
+ await expect(p2).resolves.toMatchObject({ value: 'b' })
67
+
68
+ dispose()
69
+ })
70
+ })
71
+
72
+ it('AbortSignal already aborted on entry rejects with DOMException AbortError', async () => {
73
+ await createRoot(async (dispose) => {
74
+ const ctrl = createChatPromptController()
75
+ const ac = new AbortController()
76
+ ac.abort()
77
+
78
+ const p = ctrl.handle(choiceConfig('Never shown'), ac.signal)
79
+ expect(ctrl.activePrompt()).toBeNull() // UI never installed
80
+
81
+ await expect(p).rejects.toMatchObject({ name: 'AbortError' })
82
+ dispose()
83
+ })
84
+ })
85
+
86
+ it('AbortSignal aborted during prompt rejects + clears activePrompt', async () => {
87
+ await createRoot(async (dispose) => {
88
+ const ctrl = createChatPromptController()
89
+ const ac = new AbortController()
90
+
91
+ const p = ctrl.handle(choiceConfig('Pending'), ac.signal)
92
+ expect(ctrl.activePrompt()?.title).toBe('Pending')
93
+
94
+ ac.abort()
95
+ await expect(p).rejects.toMatchObject({ name: 'AbortError' })
96
+ expect(ctrl.activePrompt()).toBeNull()
97
+
98
+ dispose()
99
+ })
100
+ })
101
+
102
+ it('abort() method rejects pending Promise with AbortError', async () => {
103
+ await createRoot(async (dispose) => {
104
+ const ctrl = createChatPromptController()
105
+
106
+ const p = ctrl.handle(choiceConfig('Will abort'))
107
+ ctrl.abort('Navigated away')
108
+
109
+ await expect(p).rejects.toMatchObject({ name: 'AbortError' })
110
+ expect(ctrl.activePrompt()).toBeNull()
111
+
112
+ dispose()
113
+ })
114
+ })
115
+
116
+ it('dismissActive resolves with dismissed: true and preserves the prompt type', async () => {
117
+ await createRoot(async (dispose) => {
118
+ const ctrl = createChatPromptController()
119
+ const p = ctrl.handle(confirmConfig('Really?'))
120
+
121
+ ctrl.dismissActive()
122
+ const r = await p
123
+ expect(r).toMatchObject({ type: 'confirm', dismissed: true })
124
+ expect(ctrl.activePrompt()).toBeNull()
125
+
126
+ dispose()
127
+ })
128
+ })
129
+
130
+ it('resolve/dismiss after abort is a no-op (double-settle protection)', async () => {
131
+ await createRoot(async (dispose) => {
132
+ const ctrl = createChatPromptController()
133
+ const p = ctrl.handle(choiceConfig('x'))
134
+
135
+ ctrl.abort()
136
+ // These should not throw or re-settle
137
+ ctrl.resolveActive(choiceResponse('ignored'))
138
+ ctrl.dismissActive()
139
+
140
+ await expect(p).rejects.toMatchObject({ name: 'AbortError' })
141
+ dispose()
142
+ })
143
+ })
144
+ })