@hachej/boring-ui-kit 0.4.60 → 0.4.61

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 (58) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +10 -10
  4. package/r/board-viewer.json +7 -7
  5. package/r/button.json +2 -2
  6. package/r/data-table.json +1 -1
  7. package/r/diagram-viewer.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/drawing-viewer.json +2 -2
  11. package/r/empty-state.json +2 -2
  12. package/r/feedback.json +7 -7
  13. package/r/file-tree.json +4 -4
  14. package/r/highlights.json +3 -3
  15. package/r/html-viewer.json +3 -3
  16. package/r/inline-question.json +3 -3
  17. package/r/kpi-stat.json +2 -2
  18. package/r/list-row.json +1 -1
  19. package/r/looks-viewer.json +3 -3
  20. package/r/maquette-viewer.json +3 -3
  21. package/r/markdown-editor.json +4 -4
  22. package/r/mockup-viewer.json +2 -2
  23. package/r/page-header.json +2 -2
  24. package/r/pi-ambient.json +9 -9
  25. package/r/pi-app-kinds.json +10 -10
  26. package/r/pi-app.json +11 -11
  27. package/r/pi-artifacts.json +3 -3
  28. package/r/pi-chat-approval.json +4 -4
  29. package/r/pi-chat-attachments.json +2 -2
  30. package/r/pi-chat-markdown.json +2 -2
  31. package/r/pi-chat-mentions.json +2 -2
  32. package/r/pi-chat-pickers.json +2 -2
  33. package/r/pi-chat-plugins.json +11 -11
  34. package/r/pi-chat-question.json +4 -3
  35. package/r/pi-chat-slash.json +3 -3
  36. package/r/pi-chat-subagent.json +2 -2
  37. package/r/pi-chat-voice.json +4 -4
  38. package/r/pi-chat.json +5 -5
  39. package/r/pi-workspace.json +3 -3
  40. package/r/placed-agent.json +5 -5
  41. package/r/provider-setup.json +2 -2
  42. package/r/record-viewer.json +1 -1
  43. package/r/registry.json +214 -213
  44. package/r/shared-chat.json +7 -7
  45. package/r/space-card.json +2 -2
  46. package/r/spaces.json +2 -2
  47. package/r/status-narrative.json +2 -2
  48. package/r/task-list-viewer.json +2 -2
  49. package/r/version-history.json +2 -2
  50. package/r/viewer-code.json +3 -3
  51. package/r/viewer-frame.json +5 -5
  52. package/r/viewer-html.json +5 -5
  53. package/r/viewer-image.json +3 -3
  54. package/r/viewer-markdown.json +6 -6
  55. package/r/viewer-pdf.json +4 -4
  56. package/r/viewers.json +6 -6
  57. package/r/windows.json +2 -2
  58. package/r/wiring-chips.json +2 -2
@@ -4,18 +4,18 @@
4
4
  "title": "Pi chat approval card",
5
5
  "description": "The Approve / Deny card for host tools wrapped with `requireApproval`. Plug it in with `cards={{ approval: ApprovalCard }}`; without it pi-chat shows a \"waiting for your answer\" notice with no answer UI.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.60",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0",
9
9
  "@earendil-works/pi-ai@~1.4.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
14
14
  ],
15
15
  "files": [
16
16
  {
17
17
  "path": "registry/pi-chat-approval/approval-card.tsx",
18
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '../_shared/icons';\nimport { waits, type ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport type { ApprovalCardProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\n// The strings `requireApproval` (`@hachej/boring-agent/approval`) asks with and puts at the start of a denial result.\nexport const APPROVE = 'Approve';\nexport const DENY = 'Deny';\nexport const DENIED_PREFIX = 'Denied by the person.';\n\n\n/** The arguments the call would run with, one line each. They come from the model's call itself: the card shows what will actually execute. */\nexport function describeArguments(call: ToolCall): { readonly name: string; readonly value: string }[] {\n return Object.entries(call.arguments as Record<string, unknown>).map(([name, value]) => {\n const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';\n return { name, value: text.length > 600 ? `${text.slice(0, 600)}…` : text };\n });\n}\n\n/**\n * The decision, once there is one. The wrapper records it in the call's details as soon as it is made, before the call finishes; a call\n * that settled without one was never decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: ApprovalDecision | undefined): ApprovalDecision | undefined {\n if (decision) return decision;\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n return result.isError && text.startsWith(DENIED_PREFIX) ? 'denied' : 'cancelled';\n}\n\nexport type { ApprovalCardProps };\n\n/** Human approval for a gated tool call: pending (Approve and Deny), then approved or denied. Answering uses the host's `answer`. */\nexport function ApprovalCard({ call, result, answer, questionId, summary, decision, live = false }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n // `pending` is the kit's waiting rule (`waits`, as the chat's status line uses); `writing` means the model is still generating the call.\n const waiting = waits({ kind: 'approval', key: call.id, call, result, live, questionId, summary: summary ?? '', ...(decision ? { decision } : {}) });\n const state = decisionOf(result, decision) ?? (sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : waiting ? 'pending' : 'writing');\n const open = state === 'pending' || state === 'writing';\n const args = describeArguments(call);\n const busy = sending !== undefined || live;\n\n async function decide(value: string) {\n if (!answer || !questionId || busy) return;\n setRefusal(undefined); setSending(value);\n try {\n const outcome = await answer(questionId, value);\n if (outcome.kind === 'answered') setSent(value);\n else setRefusal(outcome.reason || labels.decisionRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.decisionNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const approved = state === 'approved' || state === 'approved-sent', denied = state === 'denied' || state === 'denied-sent';\n\n const status = open ? labels.needsApproval : approved ? labels.approved : denied ? labels.denied : labels.notDecided;\n const unavailable = !answer || !questionId || busy;\n\n // shadcn's Confirmation recipe: an outline Item with the question and Approve/Deny actions; pending approvals are prompts, not activity.\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"approval-card\" data-state={state} data-call-id={call.id} data-tool={call.name} aria-label={labels.approvalNeeded} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {approved ? <CheckIcon aria-hidden=\"true\" /> : denied ? <XIcon aria-hidden=\"true\" /> : <ShieldAlertIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : denied ? 'destructive' : 'secondary'}>{status}</Badge></ItemDescription>\n {summary && <ItemTitle data-testid=\"approval-summary\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{summary}</ItemTitle>}\n <ItemDescription data-testid=\"approval-tool\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{labels.wantsToRun} <code className=\"font-mono\">{call.name}</code></ItemDescription>\n {args.length > 0 && (() => {\n const list = <dl data-testid=\"approval-args\" className=\"m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {args.map(item => [<dt key={`${item.name}:n`} className=\"text-muted-foreground\">{item.name}</dt>, <dd key={`${item.name}:v`} className=\"m-0 font-mono whitespace-pre-wrap [overflow-wrap:anywhere]\">{item.value}</dd>])}\n </dl>;\n // With a summary the raw arguments are the detail behind it; without one they are all the person has to decide on.\n return summary ? <details data-testid=\"approval-details\" className=\"text-xs\"><summary className=\"inline-flex min-h-8 cursor-pointer items-center text-muted-foreground select-none max-sm:min-h-11 pointer-coarse:min-h-11\">{labels.arguments}</summary>{list}</details> : list;\n })()}\n </ItemContent>\n {open && <ItemActions role=\"group\" aria-label={labels.decision}>\n <Button variant=\"default\" data-testid=\"approval-approve\" disabled={unavailable} onClick={() => { void decide(APPROVE); }}><CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.approve}</Button>\n <Button variant=\"outline\" data-testid=\"approval-deny\" disabled={unavailable} onClick={() => { void decide(DENY); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.deny}</Button>\n </ItemActions>}\n {(open && (live || !answer || !questionId)) && <ItemFooter><ItemDescription>{live ? labels.stillWritingCall : labels.decidingUnavailable}</ItemDescription></ItemFooter>}\n {/* Decided, the gated call not finished yet: whoever decided, the run carries on by itself. */}\n {(approved || denied) && !result && <ItemFooter><ItemDescription>{labels.decisionSent(approved)}</ItemDescription></ItemFooter>}\n {refusal && open && <ItemFooter><p role=\"alert\" data-testid=\"approval-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p></ItemFooter>}\n </Item>;\n}\n",
18
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '../_shared/icons';\nimport { waits, type ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport type { ApprovalCardProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\n// The strings `requireApproval` (`@hachej/boring-agent/approval`) asks with and puts at the start of a denial result.\nexport const APPROVE = 'Approve';\nexport const DENY = 'Deny';\nexport const DENIED_PREFIX = 'Denied by the person.';\n\n\n/** The arguments the call would run with, one line each. They come from the model's call itself: the card shows what will actually execute. */\nexport function describeArguments(call: ToolCall): { readonly name: string; readonly value: string }[] {\n return Object.entries(call.arguments as Record<string, unknown>).map(([name, value]) => {\n const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';\n return { name, value: text.length > 600 ? `${text.slice(0, 600)}…` : text };\n });\n}\n\n/**\n * The decision, once there is one. The wrapper records it in the call's details as soon as it is made, before the call finishes; a call\n * that settled without one was never decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: ApprovalDecision | undefined): ApprovalDecision | undefined {\n if (decision) return decision;\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n return result.isError && text.startsWith(DENIED_PREFIX) ? 'denied' : 'cancelled';\n}\n\nexport type { ApprovalCardProps };\n\n/** Human approval for a gated tool call: pending (Approve and Deny), then approved or denied. Answering uses the host's `answer`. */\nexport function ApprovalCard({ call, result, answer, questionId, summary, decision, live = false }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n // `pending` is the kit's waiting rule (`waits`, as the chat's status line uses); `writing` means the model is still generating the call.\n const waiting = waits({ kind: 'approval', key: call.id, call, result, live, questionId, summary: summary ?? '', ...(decision ? { decision } : {}) });\n const state = decisionOf(result, decision) ?? (sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : waiting ? 'pending' : 'writing');\n const open = state === 'pending' || state === 'writing';\n const args = describeArguments(call);\n const busy = sending !== undefined || live;\n\n async function decide(value: string) {\n if (!answer || !questionId || busy) return;\n setRefusal(undefined); setSending(value);\n try {\n const outcome = await answer(questionId, value);\n if (outcome.kind === 'answered') setSent(value);\n else setRefusal(outcome.reason || labels.decisionRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.decisionNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const approved = state === 'approved' || state === 'approved-sent', denied = state === 'denied' || state === 'denied-sent';\n\n const status = open ? labels.needsApproval : approved ? labels.approved : denied ? labels.denied : labels.notDecided;\n const unavailable = !answer || !questionId || busy;\n\n // shadcn's Confirmation recipe: an outline Item with the question and Approve/Deny actions; pending approvals are prompts, not activity.\n return <Item variant=\"muted\" size=\"sm\" render={<section />} data-testid=\"approval-card\" data-state={state} data-call-id={call.id} data-tool={call.name} aria-label={labels.approvalNeeded} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {approved ? <CheckIcon aria-hidden=\"true\" /> : denied ? <XIcon aria-hidden=\"true\" /> : <ShieldAlertIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : denied ? 'destructive' : 'secondary'}>{status}</Badge></ItemDescription>\n {summary && <ItemTitle data-testid=\"approval-summary\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{summary}</ItemTitle>}\n <ItemDescription data-testid=\"approval-tool\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{labels.wantsToRun} <code className=\"font-mono\">{call.name}</code></ItemDescription>\n {args.length > 0 && (() => {\n const list = <dl data-testid=\"approval-args\" className=\"m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {args.map(item => [<dt key={`${item.name}:n`} className=\"text-muted-foreground\">{item.name}</dt>, <dd key={`${item.name}:v`} className=\"m-0 font-mono whitespace-pre-wrap [overflow-wrap:anywhere]\">{item.value}</dd>])}\n </dl>;\n // With a summary the raw arguments are the detail behind it; without one they are all the person has to decide on.\n return summary ? <details data-testid=\"approval-details\" className=\"text-xs\"><summary className=\"inline-flex min-h-8 cursor-pointer items-center text-muted-foreground select-none max-sm:min-h-11 pointer-coarse:min-h-11\">{labels.arguments}</summary>{list}</details> : list;\n })()}\n </ItemContent>\n {open && <ItemActions role=\"group\" aria-label={labels.decision}>\n <Button variant=\"default\" data-testid=\"approval-approve\" disabled={unavailable} onClick={() => { void decide(APPROVE); }}><CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.approve}</Button>\n <Button variant=\"outline\" data-testid=\"approval-deny\" disabled={unavailable} onClick={() => { void decide(DENY); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.deny}</Button>\n </ItemActions>}\n {(open && (live || !answer || !questionId)) && <ItemFooter><ItemDescription>{live ? labels.stillWritingCall : labels.decidingUnavailable}</ItemDescription></ItemFooter>}\n {/* Decided, the gated call not finished yet: whoever decided, the run carries on by itself. */}\n {(approved || denied) && !result && <ItemFooter><ItemDescription>{labels.decisionSent(approved)}</ItemDescription></ItemFooter>}\n {refusal && open && <ItemFooter><p role=\"alert\" data-testid=\"approval-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p></ItemFooter>}\n </Item>;\n}\n",
19
19
  "type": "registry:component",
20
20
  "target": "components/pi-chat-approval/approval-card.tsx"
21
21
  }
@@ -8,8 +8,8 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,13 +4,13 @@
4
4
  "title": "Pi chat Markdown",
5
5
  "description": "Markdown for the chat's assistant replies: safe token-by-token rendering (raw HTML shown as text, no images fetched, http(s) links only), code blocks, tables, task lists and /command buttons. Plug it in with `renderText={markdownText}`; without it pi-chat shows replies as plain text.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.60",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0",
9
9
  "marked@^17.0.6",
10
10
  "entities@^9.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -7,8 +7,8 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -7,8 +7,8 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -7,17 +7,17 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-markdown.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-slash.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-mentions.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-pickers.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-voice.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-attachments.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-approval.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat-subagent.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-artifacts.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-markdown.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-slash.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-mentions.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-pickers.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-voice.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-attachments.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-approval.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-subagent.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-artifacts.json"
21
21
  ],
22
22
  "files": [
23
23
  {
@@ -8,13 +8,14 @@
8
8
  "@earendil-works/pi-ai@~1.4.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/inline-question.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
14
  ],
14
15
  "files": [
15
16
  {
16
17
  "path": "registry/pi-chat-question/question-card.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n // Pi appends its diagnostics to a result as a `<harness>` block of their own, for the model: never for the person.\n const text = result.content.map(part => part.type === 'text' && !/^<harness>[\\s\\S]*<\\/harness>$/.test(part.text) ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n {state === 'failed' && <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>}\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure || labels.questionNotAnswered}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n return <Card role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <CardHeader>\n <CardDescription>{labels.questionForYou}</CardDescription>\n <CardTitle data-testid=\"question-text\" className=\"[overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</CardTitle>\n </CardHeader>\n <CardContent className=\"flex flex-col gap-4\">\n {question.items.length > 0 ? <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form> : <>\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {question.options.map(value => option(value, false, () => { void submit(value); }))}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n </>}\n {live && <CardDescription>{labels.stillWritingQuestion}</CardDescription>}\n {!answer && !live && <CardDescription>{labels.answeringUnavailable}</CardDescription>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </CardContent>\n </Card>;\n}\n",
18
+ "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { InlineQuestion } from '../inline-question/inline-question';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n // Pi appends its diagnostics to a result as a `<harness>` block of their own, for the model: never for the person.\n const text = result.content.map(part => part.type === 'text' && !/^<harness>[\\s\\S]*<\\/harness>$/.test(part.text) ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n {state === 'failed' && <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>}\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure || labels.questionNotAnswered}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n // One waiting question is the shared InlineQuestion (one-tap choices, free text); the card keeps its test ids and state. A form (`questions`) stays below.\n const note = 'm-0 text-sm opacity-80';\n const hasNotice = live || !answer || !!refusal;\n const notice = hasNotice ? <>\n {live && <p className={note}>{labels.stillWritingQuestion}</p>}\n {!answer && !live && <p className={note}>{labels.answeringUnavailable}</p>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </> : undefined;\n if (question.items.length === 0) return <div data-testid=\"question-card\" data-state={state} data-call-id={call.id} className=\"my-2 text-start\">\n <InlineQuestion className=\"bg-(--chat-warning-soft) text-(--chat-warning-soft-foreground)\" id={questionId ?? call.id} kind=\"question\" text={question.question || labels.askedQuestion} options={question.options} maxOptions={6} freeText={question.allowFreeText}\n disabled={unavailable} notice={notice} onAnswer={(_, value) => { void submit(value); }}\n labels={{ question: labels.questionForYou, send: labels.answer, placeholder: labels.typeAnswer, placeholderMore: labels.ownAnswer }} />\n </div>;\n // A form of several questions: the same flat warning tint as InlineQuestion.\n return <div role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question} className=\"my-2 flex min-w-0 flex-col gap-3 rounded-(--radius) bg-(--chat-warning-soft) p-3 text-start text-(--chat-warning-soft-foreground)\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <p className=\"m-0 font-mono text-xs\">{labels.questionForYou}</p>\n <p data-testid=\"question-text\" className=\"m-0 text-sm font-medium [overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</p>\n </div>\n <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form>\n {notice}\n </div>;\n}\n",
18
19
  "type": "registry:component",
19
20
  "target": "components/pi-chat-question/question-card.tsx"
20
21
  }
@@ -4,12 +4,12 @@
4
4
  "title": "Pi chat slash menu",
5
5
  "description": "The composer's `/` menu of commands and skills (and the Commands entry of the + menu). Plug it in with `slash={{ ...config, Picker: SlashPicker }}`; without it pi-chat has no `/` menu.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.60",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -8,8 +8,8 @@
8
8
  "@earendil-works/pi-ai@~1.4.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,13 +4,13 @@
4
4
  "title": "Pi chat voice messages",
5
5
  "description": "Tap-to-record voice messages: a compatibility export of the kit's chat-react/voice recorder hook, the composer's record button and the pending voice bubble, plus useHoldToRecord. Plug it in with `voiceMessage={{ ...config, ...voiceMessagePlugin }}`; without it pi-chat has no voice button.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.60",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
14
14
  ],
15
15
  "files": [
16
16
  {
package/r/pi-chat.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The question, approval, sub-agent and artifact cards are separate items (pi-chat-question, pi-chat-approval, pi-chat-subagent, pi-artifacts); without them a question or an approval shows a \"waiting for your answer\" notice, a sub-agent call is an ordinary tool step and no artifact is offered. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.60",
8
- "@hachej/boring-files@^0.4.60",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
+ "@hachej/boring-files@^0.4.61",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "@base-ui/react@1.9.0",
@@ -17,9 +17,9 @@
17
17
  "zod@^4.6.5"
18
18
  ],
19
19
  "registryDependencies": [
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/button.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
23
23
  ],
24
24
  "files": [
25
25
  {
@@ -8,9 +8,9 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/dock.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/dock.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,11 +8,11 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-app.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-ambient.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/app-shell.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-app.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-ambient.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/app-shell.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
16
16
  ],
17
17
  "files": [
18
18
  {
@@ -8,8 +8,8 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
11
11
  ],
12
12
  "files": [
13
13
  {