@candor-ui/react 1.7.0 → 1.9.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.
@@ -20,12 +20,14 @@ function ActionButton({ label, onClick, pressed, children, }) {
20
20
  }
21
21
  /**
22
22
  * Copy, retry and rate under an agent's reply. Each action appears only when
23
- * its handler is given. Copy reports what the clipboard did. A rating is the
24
- * caller's state, so it shows only what the service recorded.
23
+ * its handler is given. Copy reports what the clipboard did: the icon stays a
24
+ * copy icon until the write settles, then becomes a tick (or a cross when the
25
+ * clipboard refused). A rating is the caller's state, so it shows only what
26
+ * the service recorded.
25
27
  */
26
28
  function MessageActions({ text, onRetry, rating, onRate, meta, className, }) {
27
29
  const { state, copy } = useCopy();
28
30
  const t = useStrings("messageActions", messageActionsStrings);
29
- return (_jsxs("div", { "data-slot": "message-actions", className: cn("flex items-center gap-0.5", className), children: [text !== undefined && (_jsx(ActionButton, { label: state === "copied" ? t("copied") : state === "failed" ? t("copyFailed") : t("copy"), onClick: () => copy(text), children: state === "copied" ? _jsx(Check, { className: "text-success-text" }) : state === "failed" ? _jsx(X, { className: "text-destructive-text" }) : _jsx(Copy, {}) })), onRetry && (_jsx(ActionButton, { label: t("retry"), onClick: onRetry, children: _jsx(RotateCw, {}) })), onRate && (_jsxs(_Fragment, { children: [_jsx(ActionButton, { label: t("good"), pressed: rating === "up", onClick: () => onRate(rating === "up" ? null : "up"), children: _jsx(ThumbsUp, { className: cn(rating === "up" && "fill-current") }) }), _jsx(ActionButton, { label: t("bad"), pressed: rating === "down", onClick: () => onRate(rating === "down" ? null : "down"), children: _jsx(ThumbsDown, { className: cn(rating === "down" && "fill-current") }) })] })), meta && _jsx("span", { className: "ms-1.5 truncate text-xs text-faint-foreground", children: meta })] }));
31
+ return (_jsxs("div", { "data-slot": "message-actions", className: cn("flex items-center gap-0.5", className), children: [text !== undefined && (_jsx(ActionButton, { label: state === "copied" ? t("copied") : state === "failed" ? t("copyFailed") : t("copy"), onClick: () => copy(text), children: state === "copied" ? (_jsx(Check, { "data-copy": "copied", className: "text-success-text animate-pop-in" })) : state === "failed" ? (_jsx(X, { "data-copy": "failed", className: "text-destructive-text animate-pop-in" })) : (_jsx(Copy, { "data-copy": state })) })), onRetry && (_jsx(ActionButton, { label: t("retry"), onClick: onRetry, children: _jsx(RotateCw, {}) })), onRate && (_jsxs(_Fragment, { children: [_jsx(ActionButton, { label: t("good"), pressed: rating === "up", onClick: () => onRate(rating === "up" ? null : "up"), children: _jsx(ThumbsUp, { className: cn(rating === "up" && "fill-current") }) }), _jsx(ActionButton, { label: t("bad"), pressed: rating === "down", onClick: () => onRate(rating === "down" ? null : "down"), children: _jsx(ThumbsDown, { className: cn(rating === "down" && "fill-current") }) })] })), meta && _jsx("span", { className: "ms-1.5 truncate text-xs text-faint-foreground", children: meta })] }));
30
32
  }
31
33
  export { MessageActions };
@@ -108,6 +108,11 @@ export type PermissionAnswer = {
108
108
  * input, nothing else. "Don't ask again" rules are offered as boxes nobody has ticked, and are kept only if the
109
109
  * person ticks them. When the agent is asking questions instead, it shows them as a short form. Words from the
110
110
  * request (its title, reason, input) are shown as text, never markup.
111
+ *
112
+ * It sits in the thread, with no backdrop and nothing that dismisses it: the only ways out are Allow and Deny, and
113
+ * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
114
+ * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
115
+ * waiting. Both stop once the answer is being sent.
111
116
  */
112
117
  declare function PermissionPrompt({ request, mode, suggestions, editable, onAllow, onDeny, busy, className, }: {
113
118
  /** The waiting request. */
@@ -122,7 +127,7 @@ declare function PermissionPrompt({ request, mode, suggestions, editable, onAllo
122
127
  onAllow: (answer: PermissionAnswer) => void;
123
128
  /** Called when the person denies it (or won't answer the questions). */
124
129
  onDeny: () => void;
125
- /** The answer is being sent: both buttons wait. */
130
+ /** The answer is being sent: both buttons wait, and the waiting pulse stops. */
126
131
  busy?: boolean | undefined;
127
132
  className?: string | undefined;
128
133
  }): React.JSX.Element;
@@ -60,6 +60,8 @@ const modeKeys = {
60
60
  bypassPermissions: "modeBypassPermissions",
61
61
  };
62
62
  const json = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 2));
63
+ /** What a press lands on when the person is doing something else, rather than clicking around the prompt. */
64
+ const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="option"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="slider"], [tabindex]:not([tabindex="-1"])';
63
65
  /** Parses edited JSON into an object, or null when it isn't one. */
64
66
  function parseObject(text) {
65
67
  try {
@@ -76,6 +78,11 @@ function parseObject(text) {
76
78
  * input, nothing else. "Don't ask again" rules are offered as boxes nobody has ticked, and are kept only if the
77
79
  * person ticks them. When the agent is asking questions instead, it shows them as a short form. Words from the
78
80
  * request (its title, reason, input) are shown as text, never markup.
81
+ *
82
+ * It sits in the thread, with no backdrop and nothing that dismisses it: the only ways out are Allow and Deny, and
83
+ * nothing else is ever counted as a denial. While the decision waits, its shield pulses slowly; a press on inert
84
+ * space outside it (the gesture that would dismiss a dialog) nudges it instead, so the person sees it is still
85
+ * waiting. Both stop once the answer is being sent.
79
86
  */
80
87
  function PermissionPrompt({ request, mode, suggestions, editable = false, onAllow, onDeny, busy = false, className, }) {
81
88
  // Keyed by the request, so an edit, a ticked rule or an answer never carries over to the next request.
@@ -101,6 +108,29 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
101
108
  if (request.defaultToNo)
102
109
  denyRef.current?.focus();
103
110
  }, [request.id, request.defaultToNo]);
111
+ // A press on inert space outside the prompt, while it waits, nudges it rather than dismissing anything.
112
+ const section = React.useRef(null);
113
+ const [nudged, setNudged] = React.useState(false);
114
+ React.useEffect(() => {
115
+ if (busy)
116
+ return;
117
+ const onPress = (e) => {
118
+ const target = e.target;
119
+ if (!target || section.current?.contains(target))
120
+ return;
121
+ if (target.closest(INTERACTIVE))
122
+ return;
123
+ setNudged(true);
124
+ };
125
+ document.addEventListener("pointerdown", onPress);
126
+ return () => document.removeEventListener("pointerdown", onPress);
127
+ }, [busy]);
128
+ React.useEffect(() => {
129
+ if (!nudged)
130
+ return;
131
+ const timer = window.setTimeout(() => setNudged(false), 1000);
132
+ return () => window.clearTimeout(timer);
133
+ }, [nudged]);
104
134
  const word = (s) => {
105
135
  const key = { allow: "ruleAllow", deny: "ruleDeny", ask: "ruleAsk", remove: "ruleRemove", mode: "setMode", directories: "directories", other: "other" }[s.action];
106
136
  const scope = s.scope && { session: "scopeSession", local: "scopeLocal", project: "scopeProject", user: "scopeUser" }[s.scope];
@@ -133,7 +163,7 @@ function PromptBody({ request, mode, suggestions, editable, onAllow, onDeny, bus
133
163
  return;
134
164
  onAllow({ ...(edited && changedKeys.length && { updatedInput: edited }), ...(ticked.length && { alwaysAllow: ticked }) });
135
165
  };
136
- return (_jsxs("section", { "data-slot": "permission-prompt", "aria-label": t("label", { title: questions ? t("questions") : title }), className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs", className), children: [_jsxs("header", { className: "flex items-start gap-2.5 bg-warning-weak/50 px-3.5 py-2.5", children: [_jsx(ShieldQuestion, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0 text-warning-text" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold leading-snug", children: questions ? t("questions") : title }), request.description && _jsx("p", { className: "mt-0.5 text-muted-foreground", children: request.description })] }), _jsx("code", { className: "shrink-0 font-mono text-2xs text-muted-foreground", children: request.tool })] }), _jsxs("dl", { className: "flex flex-col gap-1.5 px-3.5 pt-2.5 text-sm", children: [request.reason && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("why") }), _jsx("dd", { className: "min-w-0", children: request.reason })] })), mode && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("mode") }), _jsx("dd", { "data-slot": "permission-mode", children: modeKeys[mode] ? t(modeKeys[mode]) : _jsx("code", { className: "font-mono text-xs", children: mode }) })] }))] }), (request.blockedPath || request.agentId || request.mcpServer) && (_jsxs("ul", { className: "mt-1.5 flex flex-col gap-0.5 px-3.5 text-xs text-muted-foreground", children: [request.blockedPath && _jsx("li", { children: t("blockedPath", { path: request.blockedPath }) }), request.agentId && _jsx("li", { children: t("subagent") }), request.mcpServer && _jsx("li", { children: t("server", { name: request.mcpServer.name, source: request.mcpServer.source }) })] })), questions ? (_jsx("div", { className: "flex flex-col gap-4 px-3.5 py-3", children: questions.map((q) => {
166
+ return (_jsxs("section", { ref: section, "data-slot": "permission-prompt", "data-waiting": busy ? undefined : "", "data-nudged": nudged ? "" : undefined, "aria-label": t("label", { title: questions ? t("questions") : title }), className: cn("overflow-hidden rounded-xl border border-warning/50 bg-card text-sm shadow-xs [--cd-attention-color:var(--warning)]", nudged && "animate-nudge", className), children: [_jsxs("header", { className: "flex items-start gap-2.5 bg-warning-weak/50 px-3.5 py-2.5", children: [_jsx("span", { "aria-hidden": true, className: cn("mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-warning-text", !busy && "animate-attention-wait"), children: _jsx(ShieldQuestion, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold leading-snug", children: questions ? t("questions") : title }), request.description && _jsx("p", { className: "mt-0.5 text-muted-foreground", children: request.description })] }), _jsx("code", { className: "shrink-0 font-mono text-2xs text-muted-foreground", children: request.tool })] }), _jsxs("dl", { className: "flex flex-col gap-1.5 px-3.5 pt-2.5 text-sm", children: [request.reason && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("why") }), _jsx("dd", { className: "min-w-0", children: request.reason })] })), mode && (_jsxs("div", { className: "flex gap-2", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: t("mode") }), _jsx("dd", { "data-slot": "permission-mode", children: modeKeys[mode] ? t(modeKeys[mode]) : _jsx("code", { className: "font-mono text-xs", children: mode }) })] }))] }), (request.blockedPath || request.agentId || request.mcpServer) && (_jsxs("ul", { className: "mt-1.5 flex flex-col gap-0.5 px-3.5 text-xs text-muted-foreground", children: [request.blockedPath && _jsx("li", { children: t("blockedPath", { path: request.blockedPath }) }), request.agentId && _jsx("li", { children: t("subagent") }), request.mcpServer && _jsx("li", { children: t("server", { name: request.mcpServer.name, source: request.mcpServer.source }) })] })), questions ? (_jsx("div", { className: "flex flex-col gap-4 px-3.5 py-3", children: questions.map((q) => {
137
167
  const a = answers[q.id];
138
168
  return (_jsxs("fieldset", { className: "flex flex-col gap-1.5", children: [_jsxs("legend", { className: "mb-1 font-medium", children: [q.tag && _jsx("span", { className: "me-1.5 rounded-xs bg-muted px-1 text-2xs font-medium text-muted-foreground", children: q.tag }), q.prompt] }), q.options.map((o) => (_jsxs("label", { className: "flex min-h-6 items-start gap-2", children: [_jsx("input", { type: q.multiple ? "checkbox" : "radio", name: `${id}-${q.id}`, checked: a?.choice.includes(o.id) ?? false, onChange: () => choose(q, o.id), className: "mt-0.5 size-4 accent-[var(--primary)]" }), _jsxs("span", { children: [o.label, o.detail && _jsx("span", { className: "block text-xs text-muted-foreground", children: o.detail })] })] }, o.id))), q.other && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex min-h-6 items-start gap-2", children: [_jsx("input", { type: q.multiple ? "checkbox" : "radio", name: `${id}-${q.id}`, checked: a?.other ?? false, onChange: () => toggleOther(q), className: "mt-0.5 size-4 accent-[var(--primary)]" }), _jsx("span", { children: t("otherOption") })] }), a?.other && (_jsx("textarea", { "aria-label": t("otherLabel", { question: q.prompt }), rows: 2, value: a.text, onChange: (e) => setAnswers((prev) => ({ ...prev, [q.id]: { ...prev[q.id], text: e.target.value } })), className: "ms-6 block resize-none rounded-md border border-input bg-card px-2.5 py-1.5 text-sm outline-none [field-sizing:content] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30" }))] }))] }, q.id));
139
169
  }) })) : (_jsxs("div", { className: "flex flex-col gap-2 px-3.5 py-3", children: [editing ? (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: `${id}-edit`, className: "text-xs font-medium text-muted-foreground", children: t("editLabel") }), _jsx("textarea", { id: `${id}-edit`, value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, rows: Math.min(12, draft.split("\n").length + 1), "aria-invalid": invalid || undefined, "aria-describedby": `${id}-changes`, className: "block w-full resize-y rounded-md border border-input bg-card px-2.5 py-1.5 font-mono text-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:border-destructive" }), _jsx("div", { id: `${id}-changes`, role: "status", className: "text-xs", children: invalid ? (_jsx("p", { className: "text-destructive-text", children: t("editInvalid") })) : changedKeys.length ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("p", { className: "text-muted-foreground", children: t("changed") }), _jsx("ul", { className: "flex flex-col gap-1", children: changedKeys.map((k) => (_jsxs("li", { className: "grid gap-0.5 font-mono", children: [_jsx("span", { className: "text-muted-foreground", children: k }), k in proposed && _jsx("span", { className: "rounded-xs bg-destructive-weak/60 px-1 line-through", children: json(proposed[k]) }), k in edited && _jsx("span", { className: "rounded-xs bg-success-weak/70 px-1", children: json(edited[k]) })] }, k))) })] })) : (_jsx("p", { className: "text-muted-foreground", children: t("unchanged") })) }), _jsx(Button, { size: "sm", variant: "ghost", className: "self-start", onClick: () => (setEditing(false), setDraft(original)), children: t("cancelEdit") })] })) : (_jsxs(_Fragment, { children: [_jsx(Payload, { language: "json", title: t("input"), text: original }), editable && proposed && (_jsxs(Button, { size: "sm", variant: "outline", className: "self-start", onClick: () => setEditing(true), children: [_jsx(Pencil, { "aria-hidden": true }), " ", t("edit")] }))] })), suggestions && suggestions.length > 0 && (_jsxs("fieldset", { className: "mt-1 flex flex-col gap-1 rounded-lg border border-dashed px-3 py-2", children: [_jsx("legend", { className: "px-1 text-xs font-medium", children: t("remember") }), _jsxs("p", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [_jsx(Info, { "aria-hidden": true, className: "size-3" }), " ", t("rememberHint")] }), suggestions.map((s, i) => {
@@ -17,6 +17,10 @@ export declare const threadStrings: {
17
17
  * It opens at the bottom, or at `anchor` (the `id` of something in it, such as
18
18
  * a chapter divider or the card that needs the reader), and then waits for the
19
19
  * reader instead of following.
20
+ *
21
+ * While the reader is scrolled up, "Jump to latest" pulses once each time a
22
+ * new message arrives, then stays still; the jump itself scrolls smoothly
23
+ * unless the OS asks for reduced motion or data-motion is "none".
20
24
  */
21
25
  declare function Thread({ children, empty, footer, className, contentClassName, virtualize, anchor, scrollerRef, onDrop, fade, }: {
22
26
  children?: React.ReactNode | undefined;
@@ -23,6 +23,10 @@ export const threadStrings = {
23
23
  * It opens at the bottom, or at `anchor` (the `id` of something in it, such as
24
24
  * a chapter divider or the card that needs the reader), and then waits for the
25
25
  * reader instead of following.
26
+ *
27
+ * While the reader is scrolled up, "Jump to latest" pulses once each time a
28
+ * new message arrives, then stays still; the jump itself scrolls smoothly
29
+ * unless the OS asks for reduced motion or data-motion is "none".
26
30
  */
27
31
  function Thread({ children, empty, footer, className, contentClassName, virtualize = false, anchor, scrollerRef, onDrop, fade = true, }) {
28
32
  const t = useStrings("thread", threadStrings);
@@ -35,9 +39,28 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
35
39
  React.useImperativeHandle(scrollerRef, () => scroller.current, []);
36
40
  const toBottom = React.useCallback((behavior = "auto") => {
37
41
  const el = scroller.current;
38
- if (el)
39
- el.scrollTo({ top: el.scrollHeight, behavior });
42
+ if (!el)
43
+ return;
44
+ // A smooth jump only when nothing asks for stillness: the OS setting, or data-motion="none" around the thread.
45
+ const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches || el.closest('[data-motion="none"]') !== null;
46
+ el.scrollTo({ top: el.scrollHeight, behavior: still ? "auto" : behavior });
40
47
  }, []);
48
+ // New messages that arrive while the reader is scrolled up: the Jump to latest control pulses once for each.
49
+ const count = React.Children.count(children);
50
+ const lastCount = React.useRef(count);
51
+ const [arrived, setArrived] = React.useState(false);
52
+ React.useEffect(() => {
53
+ const grew = count > lastCount.current;
54
+ lastCount.current = count;
55
+ if (grew && !pinnedRef.current)
56
+ setArrived(true);
57
+ }, [count]);
58
+ React.useEffect(() => {
59
+ if (!arrived)
60
+ return;
61
+ const timer = window.setTimeout(() => setArrived(false), 1000);
62
+ return () => window.clearTimeout(timer);
63
+ }, [arrived]);
41
64
  // Open at the anchor once, when it first appears.
42
65
  const anchored = React.useRef(false);
43
66
  React.useLayoutEffect(() => {
@@ -102,10 +125,12 @@ function Thread({ children, empty, footer, className, contentClassName, virtuali
102
125
  ? (e) => {
103
126
  setFocused(e.target.closest("[data-virtual-key]")?.getAttribute("data-virtual-key") ?? null);
104
127
  }
105
- : undefined, onBlur: virtualize ? (e) => !e.currentTarget.contains(e.relatedTarget) && setFocused(null) : undefined, className: "h-full scroll-pb-14 overflow-y-auto overscroll-contain", children: _jsx("div", { ref: content, role: "log", "aria-label": t("messages"), className: cn("mx-auto flex w-full max-w-(--cd-thread-width) flex-col gap-7 px-4 py-6", contentClassName), children: !hasMessages ? (empty) : virtualize ? (_jsx(_Fragment, { children: list.rows.map((row) => "spacer" in row ? (_jsx("div", { "aria-hidden": true, style: { height: row.spacer } }, row.key)) : (_jsx("div", { ref: list.measure, "data-virtual-key": String(keyOf(row.index)), className: "flex flex-col", children: items[row.index] }, keyOf(row.index)))) })) : (children) }) }), !pinned && hasMessages && (_jsxs("button", { type: "button", onClick: () => {
106
- pinnedRef.current = true;
107
- setPinned(true);
108
- toBottom("smooth");
109
- }, className: "absolute bottom-3 left-1/2 z-10 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border bg-popover px-3 py-1.5 text-xs font-medium shadow-float animate-in fade-in-0 slide-in-from-bottom-1", children: [_jsx(ArrowDown, { "aria-hidden": true, className: "size-3" }), " ", t("jumpToLatest")] })), fade && _jsx("div", { "aria-hidden": true, "data-slot": "thread-fade", className: "pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent" })] }), footer && _jsx("div", { className: "mx-auto w-full max-w-(--cd-thread-width) px-4 pb-4", children: footer })] }));
128
+ : undefined, onBlur: virtualize ? (e) => !e.currentTarget.contains(e.relatedTarget) && setFocused(null) : undefined, className: "h-full scroll-pb-14 overflow-y-auto overscroll-contain", children: _jsx("div", { ref: content, role: "log", "aria-label": t("messages"), className: cn("mx-auto flex w-full max-w-(--cd-thread-width) flex-col gap-7 px-4 py-6", contentClassName), children: !hasMessages ? (empty) : virtualize ? (_jsx(_Fragment, { children: list.rows.map((row) => "spacer" in row ? (_jsx("div", { "aria-hidden": true, style: { height: row.spacer } }, row.key)) : (_jsx("div", { ref: list.measure, "data-virtual-key": String(keyOf(row.index)), className: "flex flex-col", children: items[row.index] }, keyOf(row.index)))) })) : (children) }) }), !pinned && hasMessages && (
129
+ // The entrance is the wrapper's, so the pulse on the button never replays it.
130
+ _jsx("span", { className: "absolute bottom-3 left-1/2 z-10 -translate-x-1/2 animate-in fade-in-0 slide-in-from-bottom-1", children: _jsxs("button", { type: "button", "data-slot": "thread-jump", "data-arrived": arrived ? "" : undefined, onClick: () => {
131
+ pinnedRef.current = true;
132
+ setPinned(true);
133
+ toBottom("smooth");
134
+ }, className: cn("inline-flex items-center gap-1.5 rounded-full border bg-popover px-3 py-1.5 text-xs font-medium shadow-float [--cd-attention-color:var(--primary)]", arrived && "animate-attention"), children: [_jsx(ArrowDown, { "aria-hidden": true, className: "size-3" }), " ", t("jumpToLatest")] }) })), fade && _jsx("div", { "aria-hidden": true, "data-slot": "thread-fade", className: "pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent" })] }), footer && _jsx("div", { className: "mx-auto w-full max-w-(--cd-thread-width) px-4 pb-4", children: footer })] }));
110
135
  }
111
136
  export { Thread };
@@ -20,7 +20,8 @@ export declare const toolCallStrings: {
20
20
  /**
21
21
  * One tool call as a compact row: what it did in plain words, its status, and
22
22
  * how long it took. Expands to the input and output. A failed call opens by
23
- * default and leads with its error.
23
+ * default and leads with its error. A call that finishes while on screen draws
24
+ * its tick or cross; one that arrives finished is drawn still.
24
25
  */
25
26
  declare function ToolCall({ call, className, }: {
26
27
  /** The call: tool name, plain-words title, status, input, output, duration or error. */
@@ -26,7 +26,8 @@ export const toolCallStrings = {
26
26
  /**
27
27
  * One tool call as a compact row: what it did in plain words, its status, and
28
28
  * how long it took. Expands to the input and output. A failed call opens by
29
- * default and leads with its error.
29
+ * default and leads with its error. A call that finishes while on screen draws
30
+ * its tick or cross; one that arrives finished is drawn still.
30
31
  */
31
32
  function ToolCall({ call, className, }) {
32
33
  const t = useStrings("toolCall", toolCallStrings);
@@ -34,7 +35,7 @@ function ToolCall({ call, className, }) {
34
35
  const failed = call.status === "failed";
35
36
  const [open, setOpen] = React.useState(failed);
36
37
  const hasDetail = Boolean(call.input || call.output || call.error || call.approval?.note || call.approval?.originalInput);
37
- return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "tool-call", "data-status": call.status, className: cn("group/tool", className), children: [_jsxs(CollapsibleTrigger, { disabled: !hasDetail, className: "flex w-full items-center gap-2.5 rounded-md px-1.5 py-1 text-start outline-none transition-colors duration-150 enabled:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx(StepMark, { status: call.status }), _jsxs("span", { className: "min-w-0 flex-1 truncate text-sm", children: [call.status === "running" ? _jsx(ShimmerLabel, { className: "font-normal", children: call.title }) : call.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(call.status)] })] }), call.approval?.originalInput !== undefined && (_jsx("span", { "data-slot": "tool-call-edited", className: "shrink-0 rounded-xs bg-warning-weak px-1 text-2xs text-warning-text", children: t("editedBeforeRun") })), call.approval?.by === "automatic" && (_jsx("span", { "data-slot": "tool-call-approval", className: "shrink-0 rounded-xs bg-muted px-1 text-2xs text-muted-foreground", children: t("approvedAutomatically") })), _jsx("code", { className: "hidden shrink-0 font-mono text-2xs text-faint-foreground sm:inline", children: call.tool }), call.duration && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: call.duration }), hasDetail && (_jsx(ChevronRight, { "aria-hidden": true, className: "rtl:-scale-x-100 size-3.5 shrink-0 text-faint-foreground transition-transform duration-200 ease-standard group-data-[state=open]/tool:[transform:rotate(90deg)]" }))] }), _jsx(CollapsibleContent, { className: "data-[state=open]:animate-in data-[state=open]:fade-in-0", children: _jsxs("div", { className: "mt-1 mb-2 ms-[1.375rem] flex flex-col gap-2 border-s ps-3.5", children: [call.error && (_jsx("p", { className: "rounded-md bg-destructive-weak px-2.5 py-1.5 text-xs text-destructive-text", children: call.error })), call.approval?.note && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [call.approval.by === "automatic" ? t("approvedAutomatically") : t("approvedByPerson"), ": ", call.approval.note] })), call.approval?.originalInput !== undefined && _jsx("p", { className: "text-xs text-muted-foreground", children: t("editedNote") }), call.input && _jsx(Payload, { language: "json", title: t("input"), text: call.input }), call.approval?.originalInput !== undefined && _jsx(Payload, { language: "json", title: t("proposed"), text: call.approval.originalInput }), call.output && _jsx(Payload, { language: call.outputLanguage ?? "text", title: t("output"), text: call.output })] }) })] }));
38
+ return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "tool-call", "data-status": call.status, className: cn("group/tool", className), children: [_jsxs(CollapsibleTrigger, { disabled: !hasDetail, className: "flex w-full items-center gap-2.5 rounded-md px-1.5 py-1 text-start outline-none transition-colors duration-150 enabled:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/80", children: [_jsx(StepMark, { status: call.status }), _jsxs("span", { className: "min-w-0 flex-1 truncate text-sm", children: [call.status === "running" ? _jsx(ShimmerLabel, { className: "font-normal", children: call.title }) : call.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(call.status)] })] }), call.approval?.originalInput !== undefined && (_jsx("span", { "data-slot": "tool-call-edited", className: "shrink-0 rounded-xs bg-warning-weak px-1 text-2xs text-warning-text", children: t("editedBeforeRun") })), call.approval?.by === "automatic" && (_jsx("span", { "data-slot": "tool-call-approval", className: "shrink-0 rounded-xs bg-muted px-1 text-2xs text-muted-foreground", children: t("approvedAutomatically") })), _jsx("code", { className: "hidden shrink-0 font-mono text-2xs text-faint-foreground sm:inline", children: call.tool }), call.duration && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: call.duration }), hasDetail && (_jsx(ChevronRight, { "aria-hidden": true, className: "rtl:-scale-x-100 size-3.5 shrink-0 text-faint-foreground transition-transform duration-200 ease-standard group-data-[state=open]/tool:[transform:rotate(90deg)]" }))] }), _jsx(CollapsibleContent, { children: _jsxs("div", { className: "mt-1 mb-2 ms-[1.375rem] flex flex-col gap-2 border-s ps-3.5", children: [call.error && (_jsx("p", { className: "rounded-md bg-destructive-weak px-2.5 py-1.5 text-xs text-destructive-text", children: call.error })), call.approval?.note && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [call.approval.by === "automatic" ? t("approvedAutomatically") : t("approvedByPerson"), ": ", call.approval.note] })), call.approval?.originalInput !== undefined && _jsx("p", { className: "text-xs text-muted-foreground", children: t("editedNote") }), call.input && _jsx(Payload, { language: "json", title: t("input"), text: call.input }), call.approval?.originalInput !== undefined && _jsx(Payload, { language: "json", title: t("proposed"), text: call.approval.originalInput }), call.output && _jsx(Payload, { language: call.outputLanguage ?? "text", title: t("output"), text: call.output })] }) })] }));
38
39
  }
39
40
  /** A run of tool calls, with a count when it's long enough to fold. */
40
41
  function ToolCallGroup({ calls, className, }) {
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./lib/know.js";
42
42
  export * from "./lib/render-prop.js";
43
43
  export * from "./chat/ai-disclosure.js";
44
44
  export * from "./chat/permission-prompt.js";
45
+ export * from "./lib/mcp-input.js";
45
46
  export * from "./lib/utils.js";
46
47
  export { ToolCall } from "./chat/tool-call.js";
47
48
  export type { ToolCall as ToolCallData } from "./lib/chat.js";
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ export * from "./lib/know.js";
42
42
  export * from "./lib/render-prop.js";
43
43
  export * from "./chat/ai-disclosure.js";
44
44
  export * from "./chat/permission-prompt.js";
45
+ export * from "./lib/mcp-input.js";
45
46
  export * from "./lib/utils.js";
46
47
  export { ToolCall } from "./chat/tool-call.js";
47
48
  export { Message } from "./chat/message.js";
@@ -0,0 +1,316 @@
1
+ /**
2
+ * MCP's "input required" results (spec 2026-07-28, multi round-trip requests) as Candor data: a server's form becomes
3
+ * questions for ClarifyingQuestions, a plain confirmation or a link to open becomes a two-answer Decision for
4
+ * ApprovalDecision, and the person's answers become the `inputResponses` the client sends when it retries the call.
5
+ * Requests it can't show (sampling, roots, anything newer) stay listed as not supported, never dropped.
6
+ *
7
+ * Pure functions on plain data, so they run in the browser or on the server, with any MCP client.
8
+ *
9
+ * Checked against https://modelcontextprotocol.io/specification/2026-07-28 (basic/patterns/mrtr, client/elicitation
10
+ * and the schema): `resultType: "input_required"`, `inputRequests` as a map keyed by the server's ids, `requestState`
11
+ * echoed back unchanged, and the retry's `inputResponses` keyed the same way. Elicitation results are
12
+ * `{ action: "accept" | "decline" | "cancel", content? }`.
13
+ */
14
+ /** A form field's schema, as MCP restricts it: a string, number, boolean or enum. Unknown keys pass through. */
15
+ export type McpFieldSchema = {
16
+ /** "string", "number", "integer", "boolean" or "array" (a multi-select enum). */
17
+ type?: string | undefined;
18
+ /** A short name for the field. */
19
+ title?: string | undefined;
20
+ /** What the field is for, in a sentence. */
21
+ description?: string | undefined;
22
+ /** The server's default. Shown as the server's suggestion; Candor never fills it in for the person. */
23
+ default?: string | number | boolean | string[] | undefined;
24
+ /** A single-select enum without titles. */
25
+ enum?: string[] | undefined;
26
+ /** Display names for `enum`, in the same order (the legacy form, still sent by older servers). */
27
+ enumNames?: string[] | undefined;
28
+ /** A single-select enum with titles. */
29
+ oneOf?: {
30
+ const: string;
31
+ title?: string | undefined;
32
+ }[] | undefined;
33
+ /** A multi-select enum's items: `enum` without titles or `anyOf` with them. */
34
+ items?: {
35
+ type?: string | undefined;
36
+ enum?: string[] | undefined;
37
+ anyOf?: {
38
+ const: string;
39
+ title?: string | undefined;
40
+ }[] | undefined;
41
+ } | undefined;
42
+ /** At least this many choices, for a multi-select. */
43
+ minItems?: number | undefined;
44
+ /** At most this many choices, for a multi-select. */
45
+ maxItems?: number | undefined;
46
+ /** At least this many characters. */
47
+ minLength?: number | undefined;
48
+ /** At most this many characters. */
49
+ maxLength?: number | undefined;
50
+ /** "email", "uri", "date" or "date-time". */
51
+ format?: string | undefined;
52
+ /** The smallest number allowed. */
53
+ minimum?: number | undefined;
54
+ /** The largest number allowed. */
55
+ maximum?: number | undefined;
56
+ };
57
+ /** An `elicitation/create` request in form mode: a message and a flat schema of primitive fields. */
58
+ export type McpElicitFormRequest = {
59
+ method: "elicitation/create";
60
+ params: {
61
+ /** "form", or left out (which means form). */
62
+ mode?: "form" | undefined;
63
+ /** Why the server asks, in its own words. */
64
+ message: string;
65
+ requestedSchema: {
66
+ type: "object";
67
+ properties: Record<string, McpFieldSchema>;
68
+ required?: string[] | undefined;
69
+ };
70
+ };
71
+ };
72
+ /** An `elicitation/create` request in URL mode: a page the person opens outside the client (sign-in, payment). */
73
+ export type McpElicitUrlRequest = {
74
+ method: "elicitation/create";
75
+ params: {
76
+ mode: "url";
77
+ /** Why the server asks, in its own words. */
78
+ message: string;
79
+ /** The page to open, only once the person agrees. */
80
+ url: string;
81
+ };
82
+ };
83
+ /**
84
+ * A result that asks for more input before the call can finish. At least one of `inputRequests` and `requestState`
85
+ * is present. Values in `inputRequests` are left `unknown`: a server may send kinds this file doesn't know.
86
+ */
87
+ export type McpInputRequiredResult = {
88
+ resultType: "input_required";
89
+ /** The server's requests, keyed by its own ids. */
90
+ inputRequests?: Record<string, unknown> | undefined;
91
+ /** Opaque to the client: sent back exactly as received, never read or changed. */
92
+ requestState?: string | undefined;
93
+ _meta?: Record<string, unknown> | undefined;
94
+ };
95
+ /** The answer to one elicitation, as MCP takes it back. `content` only when accepted in form mode. */
96
+ export type McpElicitResult = {
97
+ action: "accept" | "decline" | "cancel";
98
+ content?: Record<string, string | number | boolean | string[]> | undefined;
99
+ };
100
+ /**
101
+ * A question in ClarifyingQuestions' shape (`ClarifyingQuestion`), defined here so this free file needs nothing from
102
+ * a paid one. The question's id is the form field's name.
103
+ */
104
+ export type McpQuestion = {
105
+ id: string;
106
+ kind: "choice";
107
+ prompt: string;
108
+ options: {
109
+ id: string;
110
+ label: string;
111
+ detail?: string | undefined;
112
+ }[];
113
+ multiple?: boolean | undefined;
114
+ /** The server's default, marked as a suggestion and never pre-selected. */
115
+ recommended?: string | undefined;
116
+ /** A short label shown above the question: the field's title when it also has a description. */
117
+ tag?: string | undefined;
118
+ /** Always false: an MCP enum takes only its own values. */
119
+ other?: boolean | undefined;
120
+ } | {
121
+ id: string;
122
+ kind: "text";
123
+ prompt: string;
124
+ /** A hint for the expected format, or the server's default, never filled in. */
125
+ placeholder?: string | undefined;
126
+ /** The field isn't required: left empty, it isn't sent. */
127
+ optional?: boolean | undefined;
128
+ /** A short label shown above the question. */
129
+ tag?: string | undefined;
130
+ };
131
+ /** A person's answer, in ClarifyingQuestions' shape (`ClarifyingAnswer`). */
132
+ export type McpQuestionAnswer = {
133
+ questionId: string;
134
+ choice?: string[] | undefined;
135
+ text?: string | undefined;
136
+ };
137
+ /**
138
+ * A two-answer confirmation in ApprovalDecision's shape (`Decision`), defined here so this free file needs nothing
139
+ * from a paid one. The choices' ids are "accept" and "decline", which is what the retry sends.
140
+ */
141
+ export type McpDecision = {
142
+ id: string;
143
+ question: string;
144
+ /** What to decide on, as label and value: the page's host and its full address. */
145
+ facts?: {
146
+ label: string;
147
+ value: string;
148
+ }[] | undefined;
149
+ /** Accept first, decline second. */
150
+ choices: {
151
+ id: "accept" | "decline";
152
+ label: string;
153
+ detail?: string | undefined;
154
+ }[];
155
+ state: "open";
156
+ };
157
+ /** One of the server's requests, as Candor shows it. `key` is the server's id for it. */
158
+ export type McpInputItem =
159
+ /**
160
+ * A form: answer it with ClarifyingQuestions. `fields` and `required` are the server's schema, which
161
+ * `mcpInputResponses` checks the answers against.
162
+ */
163
+ {
164
+ key: string;
165
+ kind: "questions";
166
+ message: string;
167
+ questions: McpQuestion[];
168
+ fields: Record<string, McpFieldSchema>;
169
+ required: string[];
170
+ }
171
+ /** A form with no fields: the server asks to go ahead. Answer it with ApprovalDecision. */
172
+ | {
173
+ key: string;
174
+ kind: "confirm";
175
+ message: string;
176
+ decision: McpDecision;
177
+ }
178
+ /**
179
+ * A page to open outside the client. Show the full `url` and its `host` before the person agrees, and open it only
180
+ * once they accept (`decision`). `warnings` say why the address deserves a second look.
181
+ */
182
+ | {
183
+ key: string;
184
+ kind: "url";
185
+ message: string;
186
+ url: string;
187
+ host: string;
188
+ warnings: string[];
189
+ decision: McpDecision;
190
+ }
191
+ /** A request Candor can't show: its method (when it has one) and why. Show it; don't drop it. */
192
+ | {
193
+ key: string;
194
+ kind: "unsupported";
195
+ method?: string | undefined;
196
+ reason: string;
197
+ request: unknown;
198
+ };
199
+ /** An input-required result, ready to show. */
200
+ export type McpInputView = {
201
+ /** One per request, in the server's order. */
202
+ items: McpInputItem[];
203
+ /** Sent back exactly as received; absent when the server sent none. */
204
+ requestState?: string | undefined;
205
+ /** The server's name as your app knows it (passed in), so the person can tell which server asks. */
206
+ server?: string | undefined;
207
+ /** The name the server gives itself in the result's `_meta`. Its own claim: show it as such, if at all. */
208
+ serverClaims?: string | undefined;
209
+ };
210
+ /** A person's reply to one item, by its key. */
211
+ export type McpInputReply =
212
+ /** A form, answered: the answers from ClarifyingQuestions. */
213
+ {
214
+ key: string;
215
+ action: "accept";
216
+ answers?: McpQuestionAnswer[] | undefined;
217
+ }
218
+ /** Declined outright, or dismissed without a choice. */
219
+ | {
220
+ key: string;
221
+ action: "decline" | "cancel";
222
+ };
223
+ /** Why an answer can't be sent yet. */
224
+ export type McpInputProblem = {
225
+ /** The item's key. */
226
+ key: string;
227
+ /** The field, for a form. */
228
+ questionId?: string | undefined;
229
+ /** Which rule it broke. */
230
+ code: "unanswered" | "required" | "notAnOption" | "notANumber" | "notAnInteger" | "tooSmall" | "tooLarge" | "tooShort" | "tooLong" | "format" | "tooFew" | "tooMany";
231
+ /** The same in words, from `mcpInputWords`. */
232
+ message: string;
233
+ };
234
+ /** What a retry sends, or why it can't be sent yet. */
235
+ export type McpInputResponses = {
236
+ ok: true;
237
+ /** The retry's `inputResponses`, keyed by the server's ids. */
238
+ inputResponses: Record<string, unknown>;
239
+ /** Keys of requests nothing was sent for (not supported and not answered by your app). The server may ask again. */
240
+ notSent: string[];
241
+ } | {
242
+ ok: false;
243
+ problems: McpInputProblem[];
244
+ };
245
+ /** The words this file writes into questions, decisions and problems. Pass your own to `mcpInputView` or `mcpInputResponses`. */
246
+ export declare const mcpInputWords: {
247
+ yes: string;
248
+ no: string;
249
+ /** The extra option on a choice the server doesn't require. */
250
+ skip: string;
251
+ defaultHint: string;
252
+ formatEmail: string;
253
+ formatUri: string;
254
+ formatDate: string;
255
+ formatDateTime: string;
256
+ confirm: string;
257
+ openPage: string;
258
+ decline: string;
259
+ host: string;
260
+ address: string;
261
+ punycode: string;
262
+ notHttps: string;
263
+ badUrl: string;
264
+ unsupportedSampling: string;
265
+ unsupportedRoots: string;
266
+ unsupportedMethod: string;
267
+ unsupportedShape: string;
268
+ unsupportedField: string;
269
+ unanswered: string;
270
+ required: string;
271
+ notAnOption: string;
272
+ notANumber: string;
273
+ notAnInteger: string;
274
+ tooSmall: string;
275
+ tooLarge: string;
276
+ tooShort: string;
277
+ tooLong: string;
278
+ format: string;
279
+ tooFew: string;
280
+ tooMany: string;
281
+ };
282
+ /** The words, with any you pass in place of the defaults. */
283
+ export type McpInputWords = Partial<typeof mcpInputWords>;
284
+ /**
285
+ * Whether a tools/call (or prompts/get, resources/read) result asks for more input. A result without `resultType`
286
+ * comes from an older server and counts as complete, as the spec says.
287
+ */
288
+ export declare function isMcpInputRequired(result: unknown): result is McpInputRequiredResult;
289
+ /**
290
+ * An input-required result as items to show: forms as questions, confirmations and links as decisions, and anything
291
+ * else as a visible "not supported". Undefined when the result doesn't ask for input. `server` is your app's own name
292
+ * for the server, which the person should see beside every request.
293
+ */
294
+ export declare function mcpInputView(result: unknown, options?: {
295
+ server?: string | undefined;
296
+ words?: McpInputWords | undefined;
297
+ }): McpInputView | undefined;
298
+ /**
299
+ * The retry's `inputResponses` for the person's replies, checked against each form's schema first (the spec asks
300
+ * clients to validate). Every form, confirmation and link needs a reply; a form is sent only once every required
301
+ * field has a valid answer. `own` holds responses your app made itself, for requests Candor doesn't show (a sampling
302
+ * or roots answer), keyed by the server's ids; anything left is listed in `notSent`.
303
+ */
304
+ export declare function mcpInputResponses(view: McpInputView, replies: McpInputReply[], options?: {
305
+ own?: Record<string, unknown> | undefined;
306
+ words?: McpInputWords | undefined;
307
+ }): McpInputResponses;
308
+ /**
309
+ * The params for retrying the original request: its own params (name and arguments for tools/call) with
310
+ * `inputResponses` and the result's `requestState` exactly as received. A `requestState` from an earlier round is
311
+ * removed when this result has none, as the spec requires. Send it as a new request with a new JSON-RPC id.
312
+ */
313
+ export declare function mcpRetryParams<P extends Record<string, unknown>>(original: P, view: Pick<McpInputView, "requestState">, inputResponses: Record<string, unknown>): Omit<P, "inputResponses" | "requestState"> & {
314
+ inputResponses: Record<string, unknown>;
315
+ requestState?: string | undefined;
316
+ };