@jini-ai/ui 0.3.10 → 0.3.12
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/dist/features/mcp-ui/surfaces/bridge.js +1 -0
- package/dist/features/mcp-ui/surfaces/checkbox.d.ts +10 -0
- package/dist/features/mcp-ui/surfaces/checkbox.js +4 -0
- package/dist/features/mcp-ui/surfaces/confirmation.d.ts +34 -0
- package/dist/features/mcp-ui/surfaces/confirmation.js +160 -7
- package/dist/features/mcp-ui/surfaces/document.d.ts +11 -1
- package/dist/features/mcp-ui/surfaces/document.js +22 -2
- package/dist/features/mcp-ui/surfaces/form.js +12 -11
- package/package.json +2 -2
|
@@ -150,6 +150,7 @@ export function renderBridgeScript(spec) {
|
|
|
150
150
|
function toError(error) {
|
|
151
151
|
var failure = new Error(error && typeof error.message === "string" ? error.message : "Host returned an error");
|
|
152
152
|
if (error && typeof error.code === "number") failure.code = error.code;
|
|
153
|
+
if (error && error.data !== undefined) failure.data = error.data;
|
|
153
154
|
return failure;
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -13,6 +13,16 @@ export interface CheckboxProps {
|
|
|
13
13
|
*/
|
|
14
14
|
readonly required?: boolean;
|
|
15
15
|
readonly disabled?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* One extra `data-*` attribute on the `<input>`, escaped like every other value. For a caller
|
|
18
|
+
* that needs to look the box up by something other than its DOM `name` — e.g. the confirmation
|
|
19
|
+
* surface's per-choice ids, which are caller data and may contain characters `fieldElementId`'s
|
|
20
|
+
* `name` validation disallows.
|
|
21
|
+
*/
|
|
22
|
+
readonly dataAttribute?: {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly value: string;
|
|
25
|
+
};
|
|
16
26
|
}
|
|
17
27
|
/**
|
|
18
28
|
* Renders one boolean field.
|
|
@@ -16,10 +16,14 @@ import { fieldDescribedBy, fieldElementId, renderFieldLabel } from './document.j
|
|
|
16
16
|
*/
|
|
17
17
|
export function renderCheckbox(props) {
|
|
18
18
|
const id = fieldElementId(props.name);
|
|
19
|
+
const dataAttribute = props.dataAttribute === undefined
|
|
20
|
+
? ''
|
|
21
|
+
: ` ${escapeHtml(props.dataAttribute.name)}="${escapeHtml(props.dataAttribute.value)}"`;
|
|
19
22
|
const attributes = ` id="${escapeHtml(id)}" name="${escapeHtml(props.name)}"` +
|
|
20
23
|
(props.value === true ? ' checked' : '') +
|
|
21
24
|
(props.required === true ? ' required' : '') +
|
|
22
25
|
(props.disabled === true ? ' disabled' : '') +
|
|
26
|
+
dataAttribute +
|
|
23
27
|
fieldDescribedBy(props);
|
|
24
28
|
return `<div class="mcpui-field mcpui-field-inline">\n<input class="mcpui-checkbox" type="checkbox"${attributes}>\n<span>${renderFieldLabel(props)}</span>\n</div>`;
|
|
25
29
|
}
|
|
@@ -38,11 +38,37 @@ export interface ConfirmationToolAction {
|
|
|
38
38
|
/** Passed verbatim as the tool's `arguments`. The only safe home for a confirmation token. */
|
|
39
39
|
readonly params: Readonly<Record<string, unknown>>;
|
|
40
40
|
}
|
|
41
|
+
/** One row of an optional per-item checkbox list — see {@link ConfirmationSurfaceSpec.choices}. */
|
|
42
|
+
export interface ConfirmationChoice {
|
|
43
|
+
/**
|
|
44
|
+
* The value reported back when this box is ticked. Caller data, not a DOM name: it may contain
|
|
45
|
+
* any character, because it never becomes an HTML `name`/`id` (those are `choice-<index>` in the
|
|
46
|
+
* DOM; see `renderChoices`) — only a `data-mcpui-choice` attribute value, which any string escapes
|
|
47
|
+
* into safely.
|
|
48
|
+
*/
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly label: string;
|
|
51
|
+
readonly hint?: string;
|
|
52
|
+
}
|
|
41
53
|
export interface ConfirmationSurfaceSpec {
|
|
42
54
|
readonly title: string;
|
|
43
55
|
readonly description?: string;
|
|
44
56
|
/** The facts being agreed about. A dialog that says "delete this?" without naming *this* is not consent. */
|
|
45
57
|
readonly details?: readonly SurfaceDetail[];
|
|
58
|
+
/**
|
|
59
|
+
* Optional per-row checkboxes, rendered unchecked between the details and the warning. Ticking one
|
|
60
|
+
* never confirms anything by itself — the same property the module doc opens with, extended to a
|
|
61
|
+
* second input: a tick reaches a tool call ONLY by riding along on confirm's own trusted,
|
|
62
|
+
* past-dwell click, merged into {@link ConfirmationToolAction.params} under {@link choicesParam}
|
|
63
|
+
* at the moment of that click and never before. With no `choices`, the rendered document and the
|
|
64
|
+
* params confirm posts are byte-identical to a spec written before this field existed.
|
|
65
|
+
*/
|
|
66
|
+
readonly choices?: readonly ConfirmationChoice[];
|
|
67
|
+
/**
|
|
68
|
+
* The params key the ticked ids are posted under, merged into (never replacing) `confirm.params`.
|
|
69
|
+
* Defaults to `"overwrite"`.
|
|
70
|
+
*/
|
|
71
|
+
readonly choicesParam?: string;
|
|
46
72
|
/** A callout above the buttons — the consequence that is not obvious from the details alone. */
|
|
47
73
|
readonly warning?: string;
|
|
48
74
|
/** Styles the affirmative button as destructive. Affects presentation only; it changes no behavior. */
|
|
@@ -59,6 +85,14 @@ export interface ConfirmationSurfaceSpec {
|
|
|
59
85
|
readonly lang?: string;
|
|
60
86
|
readonly tokens?: Partial<Record<SurfaceTokenName, string>>;
|
|
61
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* How long the dialog must have been visible before a confirm click counts, in ms. A click sooner
|
|
90
|
+
* than a person could have read the dialog is ignored — the same dwell browsers put on permission
|
|
91
|
+
* prompts against clickjacking, and a guard against an automated click racing the render. Longer
|
|
92
|
+
* than the ~1 s render-to-confirm gap of the incident it was added for. The confirm button is
|
|
93
|
+
* rendered disabled and enabled when the dwell ends, so a person sees why an early click does nothing.
|
|
94
|
+
*/
|
|
95
|
+
export declare const CONFIRM_DWELL_MS = 1500;
|
|
62
96
|
export declare function renderConfirmationDocument(spec: ConfirmationSurfaceSpec): string;
|
|
63
97
|
/**
|
|
64
98
|
* Renders the confirmation dialog as an MCP-UI resource, ready for a tool result's `content` array.
|
|
@@ -30,10 +30,51 @@
|
|
|
30
30
|
import { createUIResource } from '../resource.js';
|
|
31
31
|
import { escapeHtml, escapeJsValue } from '../escape.js';
|
|
32
32
|
import { DEFAULT_SURFACE_STATUS_TEXT, SURFACE_SCRIPT_PRELUDE, renderActions, renderDetailList, renderStatusRegion, renderSurfaceDocument, renderSurfaceHeader, } from './document.js';
|
|
33
|
+
import { renderCheckbox } from './checkbox.js';
|
|
34
|
+
/**
|
|
35
|
+
* How long the dialog must have been visible before a confirm click counts, in ms. A click sooner
|
|
36
|
+
* than a person could have read the dialog is ignored — the same dwell browsers put on permission
|
|
37
|
+
* prompts against clickjacking, and a guard against an automated click racing the render. Longer
|
|
38
|
+
* than the ~1 s render-to-confirm gap of the incident it was added for. The confirm button is
|
|
39
|
+
* rendered disabled and enabled when the dwell ends, so a person sees why an early click does nothing.
|
|
40
|
+
*/
|
|
41
|
+
export const CONFIRM_DWELL_MS = 1500;
|
|
33
42
|
const DEFAULT_APP = { appName: 'jini-mcp-ui-confirmation', appVersion: '1' };
|
|
34
43
|
function isToolAction(action) {
|
|
35
44
|
return action !== undefined && 'toolName' in action;
|
|
36
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Renders the optional choice checkboxes, one unchecked box per {@link ConfirmationChoice}.
|
|
48
|
+
*
|
|
49
|
+
* Named `choice-<index>` in the DOM — never by `choice.id`, which is caller data and may contain
|
|
50
|
+
* characters `fieldElementId`'s `name` validation rejects — with the real id carried on the
|
|
51
|
+
* `data-mcpui-choice` attribute instead, which the script reads back on change.
|
|
52
|
+
*
|
|
53
|
+
* @returns `''` for an empty list, matching every other optional fragment in this document.
|
|
54
|
+
*/
|
|
55
|
+
function renderChoices(choices) {
|
|
56
|
+
if (choices.length === 0)
|
|
57
|
+
return '';
|
|
58
|
+
// Ticked state is keyed by id, so two boxes sharing one would tick and report as one.
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const choice of choices) {
|
|
61
|
+
if (seen.has(choice.id)) {
|
|
62
|
+
throw new Error(`Confirmation choices must have unique ids; ${JSON.stringify(choice.id)} appears more than once.`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(choice.id);
|
|
65
|
+
}
|
|
66
|
+
const boxes = choices
|
|
67
|
+
.map((choice, index) => renderCheckbox({
|
|
68
|
+
name: `choice-${index}`,
|
|
69
|
+
label: choice.label,
|
|
70
|
+
...(choice.hint === undefined ? {} : { hint: choice.hint }),
|
|
71
|
+
dataAttribute: { name: 'data-mcpui-choice', value: choice.id },
|
|
72
|
+
}))
|
|
73
|
+
.join('\n');
|
|
74
|
+
// Reuses form.ts's `.mcpui-fields` spacing rather than inventing a second stylesheet rule for the
|
|
75
|
+
// same "stack of controls" layout.
|
|
76
|
+
return `<fieldset class="mcpui-fields">\n${boxes}\n</fieldset>`;
|
|
77
|
+
}
|
|
37
78
|
/**
|
|
38
79
|
* Renders the confirmation dialog as a complete, self-contained HTML document.
|
|
39
80
|
*
|
|
@@ -59,13 +100,21 @@ function confirmationActions(spec) {
|
|
|
59
100
|
}
|
|
60
101
|
export function renderConfirmationDocument(spec) {
|
|
61
102
|
const text = { ...DEFAULT_SURFACE_STATUS_TEXT, ...spec.text };
|
|
103
|
+
const choicesParam = spec.choicesParam ?? 'overwrite';
|
|
104
|
+
// The ticked ids are merged into confirm.params at click time; a key already there (the token,
|
|
105
|
+
// say) would be silently replaced by an array.
|
|
106
|
+
if ((spec.choices ?? []).length > 0 && Object.prototype.hasOwnProperty.call(spec.confirm.params, choicesParam)) {
|
|
107
|
+
throw new Error(`choicesParam ${JSON.stringify(choicesParam)} collides with a key already in confirm.params.`);
|
|
108
|
+
}
|
|
62
109
|
const actions = confirmationActions(spec);
|
|
63
110
|
const warning = spec.warning === undefined ? '' : `<p class="mcpui-warning">${escapeHtml(spec.warning)}</p>`;
|
|
64
111
|
const bodyHtml = [
|
|
65
112
|
renderSurfaceHeader(spec.description === undefined ? { title: spec.title } : { title: spec.title, description: spec.description }),
|
|
66
113
|
renderDetailList(spec.details ?? []),
|
|
114
|
+
renderChoices(spec.choices ?? []),
|
|
67
115
|
warning,
|
|
68
|
-
|
|
116
|
+
// Confirm starts disabled; the script enables it once the dwell has run out.
|
|
117
|
+
renderActions(actions.map((action) => (action.id === 'confirm' ? { ...action, disabled: true } : action))),
|
|
69
118
|
renderStatusRegion(),
|
|
70
119
|
]
|
|
71
120
|
.filter((fragment) => fragment !== '')
|
|
@@ -86,30 +135,134 @@ export function renderConfirmationDocument(spec) {
|
|
|
86
135
|
${SURFACE_SCRIPT_PRELUDE}
|
|
87
136
|
var PLAN = ${escapeJsValue(plan)};
|
|
88
137
|
var TEXT = ${escapeJsValue(text)};
|
|
138
|
+
var DWELL_MS = ${CONFIRM_DWELL_MS};
|
|
139
|
+
var CHOICES_PARAM = ${escapeJsValue(choicesParam)};
|
|
140
|
+
|
|
141
|
+
// Monotonic where available: a wall clock set backwards would otherwise stretch the dwell.
|
|
142
|
+
function now() {
|
|
143
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
144
|
+
}
|
|
145
|
+
// Set while a call is in flight and for good once one settles the dialog. The disabled buttons
|
|
146
|
+
// already block clicks in a browser; this makes the guarantee not depend on the DOM honoring that.
|
|
147
|
+
var locked = false;
|
|
148
|
+
|
|
149
|
+
// When the dialog last became visible; null while hidden. A dialog that loads in a background tab
|
|
150
|
+
// has not been seen, so its dwell starts when it is shown, and restarts each time it is shown again.
|
|
151
|
+
var visibleSince = null;
|
|
152
|
+
var dwellTimer = null;
|
|
153
|
+
var confirmButton = document.querySelector('[data-mcpui-action="confirm"]');
|
|
154
|
+
|
|
155
|
+
function dwellDone() {
|
|
156
|
+
return visibleSince !== null && now() - visibleSince >= DWELL_MS;
|
|
157
|
+
}
|
|
158
|
+
// Confirm is enabled exactly when the dwell has run out and no call holds the dialog.
|
|
159
|
+
function syncConfirm() {
|
|
160
|
+
if (confirmButton !== null && !locked) confirmButton.disabled = !dwellDone();
|
|
161
|
+
}
|
|
162
|
+
// Re-checks rather than trusts the timer: setTimeout and now() are different clocks, and a timer
|
|
163
|
+
// that fired a hair early must not leave confirm disabled for good.
|
|
164
|
+
function onDwellTimer() {
|
|
165
|
+
dwellTimer = null;
|
|
166
|
+
if (visibleSince === null) return;
|
|
167
|
+
var left = DWELL_MS - (now() - visibleSince);
|
|
168
|
+
if (left > 0) dwellTimer = setTimeout(onDwellTimer, left);
|
|
169
|
+
else syncConfirm();
|
|
170
|
+
}
|
|
171
|
+
function onVisibilityChange() {
|
|
172
|
+
if (dwellTimer !== null) clearTimeout(dwellTimer);
|
|
173
|
+
dwellTimer = null;
|
|
174
|
+
visibleSince = document.visibilityState === "hidden" ? null : now();
|
|
175
|
+
syncConfirm();
|
|
176
|
+
if (visibleSince !== null) dwellTimer = setTimeout(onDwellTimer, DWELL_MS);
|
|
177
|
+
}
|
|
178
|
+
onVisibilityChange();
|
|
179
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
89
180
|
|
|
90
181
|
for (var i = 0; i < actionButtons.length; i++) {
|
|
91
182
|
actionButtons[i].addEventListener("click", onClick);
|
|
92
183
|
}
|
|
93
184
|
|
|
185
|
+
// Ticked state, tracked ourselves rather than trusted from each input's own \`.checked\` at
|
|
186
|
+
// confirm-time -- the same "only a browser-attributed action counts" rule \`onClick\` applies to
|
|
187
|
+
// the buttons, extended to the one other interactive element this document can contain. Keyed by
|
|
188
|
+
// \`data-mcpui-choice\` (the caller's id), not DOM order, so a checkbox reordering upstream could
|
|
189
|
+
// never silently swap which id a tick reports.
|
|
190
|
+
var choiceInputs = Array.prototype.slice.call(document.querySelectorAll("[data-mcpui-choice]"));
|
|
191
|
+
var checkedIds = {};
|
|
192
|
+
for (var c = 0; c < choiceInputs.length; c++) {
|
|
193
|
+
choiceInputs[c].addEventListener("change", onChoiceChange);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function onChoiceChange(event) {
|
|
197
|
+
var id = event.currentTarget.getAttribute("data-mcpui-choice");
|
|
198
|
+
if (event.isTrusted !== true) {
|
|
199
|
+
// Put the box back to what we last recorded, so a script-driven toggle from anywhere in this
|
|
200
|
+
// document can never leave the visible box and the ids confirm would send disagreeing.
|
|
201
|
+
event.currentTarget.checked = checkedIds[id] === true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event.currentTarget.checked) checkedIds[id] = true;
|
|
205
|
+
else delete checkedIds[id];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Frozen with the buttons while a call is in flight and for good once one settles the dialog, so
|
|
209
|
+
// the ticks on screen are always the ones that were sent.
|
|
210
|
+
function setChoicesDisabled(disabled) {
|
|
211
|
+
for (var k = 0; k < choiceInputs.length; k++) choiceInputs[k].disabled = disabled;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// DOM order, not \`for...in\` over checkedIds: object key order for arbitrary caller-supplied
|
|
215
|
+
// strings is not something to depend on, even where every engine we run on happens to preserve it.
|
|
216
|
+
function checkedChoiceIds() {
|
|
217
|
+
var ids = [];
|
|
218
|
+
for (var j = 0; j < choiceInputs.length; j++) {
|
|
219
|
+
var choiceId = choiceInputs[j].getAttribute("data-mcpui-choice");
|
|
220
|
+
// Both: a trusted tick recorded, and the box still showing it. Never send what isn't on screen.
|
|
221
|
+
if (checkedIds[choiceId] === true && choiceInputs[j].checked) ids.push(choiceId);
|
|
222
|
+
}
|
|
223
|
+
return ids;
|
|
224
|
+
}
|
|
225
|
+
|
|
94
226
|
function onClick(event) {
|
|
95
|
-
|
|
227
|
+
// Only a click the browser attributes to the user counts. element.click() and dispatchEvent
|
|
228
|
+
// from any script in this document produce isTrusted === false.
|
|
229
|
+
if (event.isTrusted !== true || locked) return;
|
|
230
|
+
var action = event.currentTarget.getAttribute("data-mcpui-action");
|
|
231
|
+
var step = PLAN[action];
|
|
96
232
|
if (step === undefined) return;
|
|
233
|
+
// Cancel is exempt: backing out early is never the harm this guards against.
|
|
234
|
+
if (action === "confirm" && !dwellDone()) return;
|
|
235
|
+
locked = true;
|
|
236
|
+
setChoicesDisabled(true);
|
|
97
237
|
if (step === null) {
|
|
98
238
|
setBusy(true);
|
|
99
239
|
setStatus(TEXT.dismissed, "dismissed");
|
|
100
240
|
api.requestTeardown();
|
|
101
241
|
return;
|
|
102
242
|
}
|
|
243
|
+
// Choices ride only on confirm, and only when this document has any -- with none, \`params\` stays
|
|
244
|
+
// the exact \`step.params\` reference PLAN was built from, byte-identical to before this existed.
|
|
245
|
+
var params = step.params;
|
|
246
|
+
if (action === "confirm" && choiceInputs.length > 0) {
|
|
247
|
+
var merged = {};
|
|
248
|
+
var key;
|
|
249
|
+
for (key in step.params) if (Object.prototype.hasOwnProperty.call(step.params, key)) merged[key] = step.params[key];
|
|
250
|
+
merged[CHOICES_PARAM] = checkedChoiceIds();
|
|
251
|
+
params = merged;
|
|
252
|
+
}
|
|
103
253
|
setBusy(true);
|
|
104
254
|
setStatus(TEXT.working, "pending");
|
|
105
|
-
api.callTool(step.toolName,
|
|
255
|
+
api.callTool(step.toolName, params).then(function () {
|
|
106
256
|
setStatus(TEXT.done, "done");
|
|
107
257
|
api.requestTeardown();
|
|
108
258
|
}, function (error) {
|
|
109
|
-
// Re-enabled on failure
|
|
110
|
-
// or cancel
|
|
111
|
-
|
|
112
|
-
|
|
259
|
+
// Re-enabled on failure (a rejected call did not happen, so the human must be able to retry
|
|
260
|
+
// or cancel), unless the Host says this dialog is no longer pending -- see reportCallFailure.
|
|
261
|
+
if (!reportCallFailure(error)) return;
|
|
262
|
+
locked = false;
|
|
263
|
+
setChoicesDisabled(false);
|
|
264
|
+
// reportCallFailure re-enabled every button; confirm still waits out an unfinished dwell.
|
|
265
|
+
syncConfirm();
|
|
113
266
|
});
|
|
114
267
|
}
|
|
115
268
|
}());`;
|
|
@@ -8,7 +8,7 @@ import { type SurfaceTokenName } from './tokens.js';
|
|
|
8
8
|
* be a color no caller could reskin. `var(--x, fallback)` appears only where a value is genuinely
|
|
9
9
|
* optional.
|
|
10
10
|
*/
|
|
11
|
-
export declare const SURFACE_BASE_CSS = "*, *::before, *::after { box-sizing: border-box; }\nhtml, body { margin: 0; padding: 0; }\nbody {\n /* Transparent, not var(--jini-mcpui-bg): this document has no page of its own to paint. It is an\n allow-scripts-only sandboxed iframe embedded directly in a host's chat transcript, and html's\n background is left at its (also transparent) initial value, so the host's own pane shows\n straight through to .mcpui-surface below rather than through a second, unrelated rectangle of\n fill color sitting between the two. --jini-mcpui-bg stays declared for a caller styling\n something else via extraCss -- nothing in this stylesheet reads it anymore. */\n background: transparent;\n color: var(--jini-mcpui-text);\n font-family: var(--jini-mcpui-font);\n font-size: 14px;\n line-height: 1.5;\n padding: 16px;\n -webkit-font-smoothing: antialiased;\n}\n.mcpui-surface {\n background: linear-gradient(135deg,\n color-mix(in srgb, var(--jini-mcpui-accent) 5%, var(--jini-mcpui-panel)) 0%,\n var(--jini-mcpui-panel) 60%);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 18%, var(--jini-mcpui-border));\n border-radius: var(--jini-mcpui-radius-md);\n box-shadow: var(--jini-mcpui-surface-shadow);\n padding: 18px 20px 16px;\n max-width: 640px;\n margin: 0 auto;\n}\n.mcpui-title {\n margin: 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-description { margin: 6px 0 0; color: var(--jini-mcpui-text-muted); }\n.mcpui-details {\n display: grid;\n grid-template-columns: max-content minmax(0, 1fr);\n gap: 4px 14px;\n margin: 14px 0 0;\n}\n.mcpui-details dt { color: var(--jini-mcpui-text-soft); font-weight: 500; }\n.mcpui-details dd { margin: 0; overflow-wrap: anywhere; color: var(--jini-mcpui-text); }\n.mcpui-warning {\n margin: 14px 0 0;\n padding: 9px 11px;\n border-radius: var(--jini-mcpui-radius-md);\n border: 1px solid var(--jini-mcpui-danger);\n background: var(--jini-mcpui-danger-tint);\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-fields { display: flex; flex-direction: column; gap: 12px; margin: 16px 0 0; border: 0; padding: 0; }\n.mcpui-field { display: flex; flex-direction: column; gap: 4px; }\n.mcpui-field-inline { flex-direction: row; align-items: flex-start; gap: 8px; }\n.mcpui-label { font-weight: 500; color: var(--jini-mcpui-text-strong); }\n.mcpui-required { color: var(--jini-mcpui-accent); margin-left: 2px; }\n.mcpui-hint { color: var(--jini-mcpui-text-soft); font-size: 13px; }\n.mcpui-input, .mcpui-select, .mcpui-textarea {\n font: inherit;\n color: var(--jini-mcpui-text);\n background: var(--jini-mcpui-panel);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n border-radius: var(--jini-mcpui-radius-sm);\n padding: 7px 9px;\n width: 100%;\n min-width: 0;\n}\n.mcpui-textarea { resize: vertical; min-height: 72px; font-family: var(--jini-mcpui-font); }\n.mcpui-checkbox { margin: 3px 0 0; accent-color: var(--jini-mcpui-accent); }\n.mcpui-choice-group { border: 0; padding: 0; margin: 0; min-width: 0; }\n.mcpui-choice { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; margin: 6px 0 0; }\n.mcpui-choice-label { color: var(--jini-mcpui-text); font-weight: 400; }\n.mcpui-input:focus-visible, .mcpui-select:focus-visible, .mcpui-textarea:focus-visible,\n.mcpui-checkbox:focus-visible, .mcpui-button:focus-visible {\n outline: 2px solid var(--jini-mcpui-accent-ring);\n outline-offset: 1px;\n border-color: var(--jini-mcpui-accent);\n}\n.mcpui-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }\n.mcpui-button {\n font: inherit;\n font-weight: 500;\n padding: 7px 15px;\n border-radius: var(--jini-mcpui-radius-pill);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n background: var(--jini-mcpui-panel);\n color: var(--jini-mcpui-text);\n cursor: pointer;\n}\n.mcpui-button:hover:not([disabled]) { background: var(--jini-mcpui-accent-tint); }\n.mcpui-button-primary {\n background: var(--jini-mcpui-accent);\n border-color: var(--jini-mcpui-accent);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-primary:hover:not([disabled]) { background: var(--jini-mcpui-accent-hover); border-color: var(--jini-mcpui-accent-hover); }\n.mcpui-button-danger {\n background: var(--jini-mcpui-danger);\n border-color: var(--jini-mcpui-danger);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-danger:hover:not([disabled]) { background: var(--jini-mcpui-danger-hover); border-color: var(--jini-mcpui-danger-hover); }\n.mcpui-button[disabled] { opacity: 0.55; cursor: default; }\n.mcpui-status { margin: 12px 0 0; min-height: 1.4em; color: var(--jini-mcpui-text-muted); }\n.mcpui-status[data-state=\"failed\"], .mcpui-status[data-state=\"invalid\"] { color: var(--jini-mcpui-danger); }\n.mcpui-status[data-state=\"done\"] { color: var(--jini-mcpui-text-strong); }\n/* Neither a plain success nor a failure \u2014 e.g. an outcome surface reporting \"uploaded, not yet\n reachable\" (outcome.ts's own state: partial). Distinct from both --jini-mcpui-danger (would\n wrongly read as \"this failed\") and --jini-mcpui-text-strong (would wrongly read as \"this is\n fully done\"); the accent token reads as \"worth a second look\" without asserting either. */\n.mcpui-status[data-state=\"partial\"] { color: var(--jini-mcpui-accent); }\n.mcpui-code { font-family: var(--jini-mcpui-font-mono); font-size: 13px; color: var(--jini-mcpui-text-faint); }";
|
|
11
|
+
export declare const SURFACE_BASE_CSS = "*, *::before, *::after { box-sizing: border-box; }\nhtml, body { margin: 0; padding: 0; }\nbody {\n /* Transparent, not var(--jini-mcpui-bg): this document has no page of its own to paint. It is an\n allow-scripts-only sandboxed iframe embedded directly in a host's chat transcript, and html's\n background is left at its (also transparent) initial value, so the host's own pane shows\n straight through to .mcpui-surface below rather than through a second, unrelated rectangle of\n fill color sitting between the two. --jini-mcpui-bg stays declared for a caller styling\n something else via extraCss -- nothing in this stylesheet reads it anymore. */\n background: transparent;\n color: var(--jini-mcpui-text);\n font-family: var(--jini-mcpui-font);\n font-size: 14px;\n line-height: 1.5;\n padding: 16px;\n -webkit-font-smoothing: antialiased;\n}\n.mcpui-surface {\n background: linear-gradient(135deg,\n color-mix(in srgb, var(--jini-mcpui-accent) 5%, var(--jini-mcpui-panel)) 0%,\n var(--jini-mcpui-panel) 60%);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 18%, var(--jini-mcpui-border));\n border-radius: var(--jini-mcpui-radius-md);\n box-shadow: var(--jini-mcpui-surface-shadow);\n padding: 18px 20px 16px;\n max-width: 640px;\n margin: 0 auto;\n}\n.mcpui-title {\n margin: 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-description { margin: 6px 0 0; color: var(--jini-mcpui-text-muted); }\n.mcpui-details {\n display: grid;\n grid-template-columns: max-content minmax(0, 1fr);\n gap: 4px 14px;\n margin: 14px 0 0;\n}\n.mcpui-details dt { color: var(--jini-mcpui-text-soft); font-weight: 500; }\n.mcpui-details dd { margin: 0; overflow-wrap: anywhere; color: var(--jini-mcpui-text); }\n.mcpui-warning {\n margin: 14px 0 0;\n padding: 9px 11px;\n border-radius: var(--jini-mcpui-radius-md);\n border: 1px solid var(--jini-mcpui-danger);\n background: var(--jini-mcpui-danger-tint);\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-fields { display: flex; flex-direction: column; gap: 12px; margin: 16px 0 0; border: 0; padding: 0; }\n.mcpui-field { display: flex; flex-direction: column; gap: 4px; }\n.mcpui-field-inline { flex-direction: row; align-items: flex-start; gap: 8px; }\n.mcpui-label { font-weight: 500; color: var(--jini-mcpui-text-strong); }\n.mcpui-required { color: var(--jini-mcpui-accent); margin-left: 2px; }\n.mcpui-hint { color: var(--jini-mcpui-text-soft); font-size: 13px; }\n.mcpui-input, .mcpui-select, .mcpui-textarea {\n font: inherit;\n color: var(--jini-mcpui-text);\n background: var(--jini-mcpui-panel);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n border-radius: var(--jini-mcpui-radius-sm);\n padding: 7px 9px;\n width: 100%;\n min-width: 0;\n}\n.mcpui-textarea { resize: vertical; min-height: 72px; font-family: var(--jini-mcpui-font); }\n.mcpui-checkbox { margin: 3px 0 0; accent-color: var(--jini-mcpui-accent); }\n.mcpui-choice-group { border: 0; padding: 0; margin: 0; min-width: 0; }\n.mcpui-choice { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; margin: 6px 0 0; }\n.mcpui-choice-label { color: var(--jini-mcpui-text); font-weight: 400; }\n.mcpui-input:focus-visible, .mcpui-select:focus-visible, .mcpui-textarea:focus-visible,\n.mcpui-checkbox:focus-visible, .mcpui-button:focus-visible {\n outline: 2px solid var(--jini-mcpui-accent-ring);\n outline-offset: 1px;\n border-color: var(--jini-mcpui-accent);\n}\n.mcpui-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }\n.mcpui-button {\n font: inherit;\n font-weight: 500;\n padding: 7px 15px;\n border-radius: var(--jini-mcpui-radius-pill);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n background: var(--jini-mcpui-panel);\n color: var(--jini-mcpui-text);\n cursor: pointer;\n}\n.mcpui-button:hover:not([disabled]) { background: var(--jini-mcpui-accent-tint); }\n.mcpui-button-primary {\n background: var(--jini-mcpui-accent);\n border-color: var(--jini-mcpui-accent);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-primary:hover:not([disabled]) { background: var(--jini-mcpui-accent-hover); border-color: var(--jini-mcpui-accent-hover); }\n.mcpui-button-danger {\n background: var(--jini-mcpui-danger);\n border-color: var(--jini-mcpui-danger);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-danger:hover:not([disabled]) { background: var(--jini-mcpui-danger-hover); border-color: var(--jini-mcpui-danger-hover); }\n.mcpui-button[disabled] { opacity: 0.55; cursor: default; }\n.mcpui-status { margin: 12px 0 0; min-height: 1.4em; color: var(--jini-mcpui-text-muted); }\n.mcpui-status[data-state=\"failed\"], .mcpui-status[data-state=\"invalid\"], .mcpui-status[data-state=\"expired\"] { color: var(--jini-mcpui-danger); }\n.mcpui-status[data-state=\"done\"] { color: var(--jini-mcpui-text-strong); }\n/* Neither a plain success nor a failure \u2014 e.g. an outcome surface reporting \"uploaded, not yet\n reachable\" (outcome.ts's own state: partial). Distinct from both --jini-mcpui-danger (would\n wrongly read as \"this failed\") and --jini-mcpui-text-strong (would wrongly read as \"this is\n fully done\"); the accent token reads as \"worth a second look\" without asserting either. */\n.mcpui-status[data-state=\"partial\"] { color: var(--jini-mcpui-accent); }\n.mcpui-code { font-family: var(--jini-mcpui-font-mono); font-size: 13px; color: var(--jini-mcpui-text-faint); }";
|
|
12
12
|
/**
|
|
13
13
|
* The Content-Security-Policy every generated surface declares in its own `<head>`.
|
|
14
14
|
*
|
|
@@ -76,6 +76,12 @@ export interface SurfaceDetail {
|
|
|
76
76
|
export declare function renderDetailList(details: readonly SurfaceDetail[]): string;
|
|
77
77
|
/** Renders the polite live region every surface reports outcomes through. Its id is fixed so surface scripts can find it. */
|
|
78
78
|
export declare function renderStatusRegion(): string;
|
|
79
|
+
/**
|
|
80
|
+
* The `error.data.code` a Host relays when the dialog a call came from is no longer waiting for an
|
|
81
|
+
* answer (answered elsewhere, expired, or its run is gone). Retrying cannot succeed, so a surface
|
|
82
|
+
* that sees it keeps its buttons disabled instead of re-arming them.
|
|
83
|
+
*/
|
|
84
|
+
export declare const SURFACE_NOT_PENDING_ERROR_CODE = "SURFACE_NOT_PENDING";
|
|
79
85
|
/** The id {@link renderStatusRegion} emits and {@link SURFACE_SCRIPT_PRELUDE} looks up. */
|
|
80
86
|
export declare const SURFACE_STATUS_ELEMENT_ID = "mcpui-status";
|
|
81
87
|
/**
|
|
@@ -96,6 +102,8 @@ export interface SurfaceStatusText {
|
|
|
96
102
|
readonly dismissed: string;
|
|
97
103
|
/** Prefixed to the comma-joined labels of unfilled required fields. */
|
|
98
104
|
readonly missingPrefix: string;
|
|
105
|
+
/** Shown when the Host says the dialog is no longer pending; the buttons stay disabled. */
|
|
106
|
+
readonly expired: string;
|
|
99
107
|
}
|
|
100
108
|
export declare const DEFAULT_SURFACE_STATUS_TEXT: SurfaceStatusText;
|
|
101
109
|
/**
|
|
@@ -150,6 +158,8 @@ export interface SurfaceAction {
|
|
|
150
158
|
* than that sandbox; every current builder passes `button` (the default).
|
|
151
159
|
*/
|
|
152
160
|
readonly type?: 'button' | 'submit';
|
|
161
|
+
/** Renders the button `disabled`; the surface script decides when to enable it. */
|
|
162
|
+
readonly disabled?: boolean;
|
|
153
163
|
}
|
|
154
164
|
/**
|
|
155
165
|
* Renders the action row.
|
|
@@ -128,7 +128,7 @@ body {
|
|
|
128
128
|
.mcpui-button-danger:hover:not([disabled]) { background: var(--jini-mcpui-danger-hover); border-color: var(--jini-mcpui-danger-hover); }
|
|
129
129
|
.mcpui-button[disabled] { opacity: 0.55; cursor: default; }
|
|
130
130
|
.mcpui-status { margin: 12px 0 0; min-height: 1.4em; color: var(--jini-mcpui-text-muted); }
|
|
131
|
-
.mcpui-status[data-state="failed"], .mcpui-status[data-state="invalid"] { color: var(--jini-mcpui-danger); }
|
|
131
|
+
.mcpui-status[data-state="failed"], .mcpui-status[data-state="invalid"], .mcpui-status[data-state="expired"] { color: var(--jini-mcpui-danger); }
|
|
132
132
|
.mcpui-status[data-state="done"] { color: var(--jini-mcpui-text-strong); }
|
|
133
133
|
/* Neither a plain success nor a failure — e.g. an outcome surface reporting "uploaded, not yet
|
|
134
134
|
reachable" (outcome.ts's own state: partial). Distinct from both --jini-mcpui-danger (would
|
|
@@ -217,6 +217,12 @@ export function renderDetailList(details) {
|
|
|
217
217
|
export function renderStatusRegion() {
|
|
218
218
|
return `<p class="mcpui-status" id="${SURFACE_STATUS_ELEMENT_ID}" role="status" aria-live="polite" data-state="idle"></p>`;
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* The `error.data.code` a Host relays when the dialog a call came from is no longer waiting for an
|
|
222
|
+
* answer (answered elsewhere, expired, or its run is gone). Retrying cannot succeed, so a surface
|
|
223
|
+
* that sees it keeps its buttons disabled instead of re-arming them.
|
|
224
|
+
*/
|
|
225
|
+
export const SURFACE_NOT_PENDING_ERROR_CODE = 'SURFACE_NOT_PENDING';
|
|
220
226
|
/** The id {@link renderStatusRegion} emits and {@link SURFACE_SCRIPT_PRELUDE} looks up. */
|
|
221
227
|
export const SURFACE_STATUS_ELEMENT_ID = 'mcpui-status';
|
|
222
228
|
export const DEFAULT_SURFACE_STATUS_TEXT = {
|
|
@@ -225,6 +231,7 @@ export const DEFAULT_SURFACE_STATUS_TEXT = {
|
|
|
225
231
|
failedPrefix: 'Failed: ',
|
|
226
232
|
dismissed: 'Dismissed.',
|
|
227
233
|
missingPrefix: 'Please complete: ',
|
|
234
|
+
expired: 'This dialog expired. Ask again.',
|
|
228
235
|
};
|
|
229
236
|
/**
|
|
230
237
|
* The JavaScript preamble every surface script opens with: the bridge handle, the status region,
|
|
@@ -248,6 +255,19 @@ function setBusy(busy) {
|
|
|
248
255
|
}
|
|
249
256
|
function describeError(error) {
|
|
250
257
|
return error && typeof error.message === "string" ? error.message : String(error);
|
|
258
|
+
}
|
|
259
|
+
// Reports a rejected tool call and returns whether the human may try again. A rejected call did not
|
|
260
|
+
// happen, so normally the buttons re-arm; but a dialog the Host no longer waits on cannot succeed,
|
|
261
|
+
// and live-looking buttons there only invite clicks that fail the same way. Reads TEXT, which each
|
|
262
|
+
// surface script declares after this prelude.
|
|
263
|
+
function reportCallFailure(error) {
|
|
264
|
+
if (error && error.data && error.data.code === ${JSON.stringify(SURFACE_NOT_PENDING_ERROR_CODE)}) {
|
|
265
|
+
setStatus(TEXT.expired, "expired");
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
setBusy(false);
|
|
269
|
+
setStatus(TEXT.failedPrefix + describeError(error), "failed");
|
|
270
|
+
return true;
|
|
251
271
|
}`;
|
|
252
272
|
/**
|
|
253
273
|
* A field name that is simultaneously an HTML `name`, part of a DOM `id`, and a key in the JSON
|
|
@@ -319,7 +339,7 @@ export function renderActions(actions) {
|
|
|
319
339
|
// would accept. That is unenforced on purpose (see the function doc above: `page.find_elements`
|
|
320
340
|
// can never reach this document to resolve it either way), so failing loudly here would refuse
|
|
321
341
|
// markup for a reason that can never matter to the one consumer that can actually query it.
|
|
322
|
-
return ` <button type="${action.type ?? 'button'}" class="mcpui-button${variantClass}" data-mcpui-action="${escapeHtml(action.id)}" ${AGENT_ELEMENT_ATTRIBUTE}="mcpui-action-${escapeHtml(action.id)}" ${AGENT_ROLE_ATTRIBUTE}="button" ${AGENT_LABEL_ATTRIBUTE}="${label}">${label}</button>`;
|
|
342
|
+
return ` <button type="${action.type ?? 'button'}" class="mcpui-button${variantClass}" data-mcpui-action="${escapeHtml(action.id)}" ${AGENT_ELEMENT_ATTRIBUTE}="mcpui-action-${escapeHtml(action.id)}" ${AGENT_ROLE_ATTRIBUTE}="button" ${AGENT_LABEL_ATTRIBUTE}="${label}"${action.disabled === true ? ' disabled' : ''}>${label}</button>`;
|
|
323
343
|
})
|
|
324
344
|
.join('\n');
|
|
325
345
|
return `<div class="mcpui-actions">\n${buttons}\n</div>`;
|
|
@@ -120,7 +120,8 @@ ${SURFACE_SCRIPT_PRELUDE}
|
|
|
120
120
|
//
|
|
121
121
|
// Deliberately NOT cleared on success -- the success path tears the surface down, so re-arming
|
|
122
122
|
// would only reopen the window. Cleared on failure, matching what setBusy(false) already does
|
|
123
|
-
// there: a rejected call did not happen, and the human must be able to retry
|
|
123
|
+
// there: a rejected call did not happen, and the human must be able to retry -- unless the Host
|
|
124
|
+
// says the dialog is no longer pending, where a retry cannot succeed (see reportCallFailure).
|
|
124
125
|
var pending = false;
|
|
125
126
|
|
|
126
127
|
function runSubmit() {
|
|
@@ -145,9 +146,7 @@ ${SURFACE_SCRIPT_PRELUDE}
|
|
|
145
146
|
setStatus(TEXT.done, "done");
|
|
146
147
|
api.requestTeardown();
|
|
147
148
|
}, function (error) {
|
|
148
|
-
pending = false;
|
|
149
|
-
setBusy(false);
|
|
150
|
-
setStatus(TEXT.failedPrefix + describeError(error), "failed");
|
|
149
|
+
if (reportCallFailure(error)) pending = false;
|
|
151
150
|
});
|
|
152
151
|
}
|
|
153
152
|
|
|
@@ -155,8 +154,10 @@ ${SURFACE_SCRIPT_PRELUDE}
|
|
|
155
154
|
// native behavior a single-line text input gets inside a real form. Scoped to the same controls
|
|
156
155
|
// the browser would have honored it for -- type=text/type=number inputs -- so Enter still inserts
|
|
157
156
|
// a newline in a multi-line textarea and still just toggles a checkbox.
|
|
157
|
+
// Only what the browser attributes to a person counts: element.click(), dispatchEvent and a
|
|
158
|
+
// synthetic keydown from any script produce isTrusted === false, and submit and cancel call tools.
|
|
158
159
|
form.addEventListener("keydown", function (event) {
|
|
159
|
-
if (event.key !== "Enter") return;
|
|
160
|
+
if (event.isTrusted !== true || event.key !== "Enter") return;
|
|
160
161
|
var target = event.target;
|
|
161
162
|
if (!target || target.tagName !== "INPUT") return;
|
|
162
163
|
var type = (target.getAttribute("type") || "text").toLowerCase();
|
|
@@ -168,12 +169,14 @@ ${SURFACE_SCRIPT_PRELUDE}
|
|
|
168
169
|
for (var b = 0; b < actionButtons.length; b++) {
|
|
169
170
|
var action = actionButtons[b].getAttribute("data-mcpui-action");
|
|
170
171
|
if (action === "submit") {
|
|
171
|
-
actionButtons[b].addEventListener("click",
|
|
172
|
+
actionButtons[b].addEventListener("click", function (event) {
|
|
173
|
+
if (event.isTrusted === true) runSubmit();
|
|
174
|
+
});
|
|
172
175
|
continue;
|
|
173
176
|
}
|
|
174
177
|
if (action !== "cancel") continue;
|
|
175
|
-
actionButtons[b].addEventListener("click", function () {
|
|
176
|
-
if (pending) return;
|
|
178
|
+
actionButtons[b].addEventListener("click", function (event) {
|
|
179
|
+
if (event.isTrusted !== true || pending) return;
|
|
177
180
|
pending = true;
|
|
178
181
|
setBusy(true);
|
|
179
182
|
if (CANCEL === null) {
|
|
@@ -186,9 +189,7 @@ ${SURFACE_SCRIPT_PRELUDE}
|
|
|
186
189
|
setStatus(TEXT.dismissed, "dismissed");
|
|
187
190
|
api.requestTeardown();
|
|
188
191
|
}, function (error) {
|
|
189
|
-
pending = false;
|
|
190
|
-
setBusy(false);
|
|
191
|
-
setStatus(TEXT.failedPrefix + describeError(error), "failed");
|
|
192
|
+
if (reportCallFailure(error)) pending = false;
|
|
192
193
|
});
|
|
193
194
|
});
|
|
194
195
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Generic, product-neutral React UI primitives and feature domains. Framework-free types, decision rules, catalogs, and host port contracts live behind ./core (no React, no DOM). The Excalidraw and Lexical editors live behind ./sketch-editor and ./lexical-rich-text-editor; the GrapesJS-backed inline HTML editor lives behind ./html-editor.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
"shiki": "^1.24.4",
|
|
108
108
|
"tailwind-merge": "^2.6.0",
|
|
109
109
|
"zod": "^3.25.76",
|
|
110
|
-
"@jini-ai/agentic": "0.3.6",
|
|
111
110
|
"@jini-ai/platform": "0.3.0",
|
|
111
|
+
"@jini-ai/agentic": "0.3.6",
|
|
112
112
|
"@jini-ai/protocol": "0.3.0"
|
|
113
113
|
},
|
|
114
114
|
"devDependencies": {
|