@hachej/boring-ui-kit 0.4.57 → 0.4.59

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 (59) hide show
  1. package/bin/boring-ui-blocks.mjs +49 -4
  2. package/dist/chat-react-conversations.d.ts +7 -1
  3. package/dist/chat-react-conversations.js +58 -18
  4. package/package.json +6 -6
  5. package/r/_shared.json +19 -0
  6. package/r/agent-target.json +4 -4
  7. package/r/app-shell.json +17 -17
  8. package/r/board-viewer.json +10 -10
  9. package/r/button.json +4 -4
  10. package/r/data-table.json +21 -0
  11. package/r/diagram-viewer.json +6 -4
  12. package/r/dock.json +1 -1
  13. package/r/documents.json +13 -14
  14. package/r/drawing-viewer.json +6 -4
  15. package/r/empty-state.json +22 -0
  16. package/r/feedback.json +14 -14
  17. package/r/file-tree.json +4 -4
  18. package/r/highlights.json +3 -3
  19. package/r/html-viewer.json +3 -3
  20. package/r/kpi-stat.json +22 -0
  21. package/r/list-row.json +21 -0
  22. package/r/looks-viewer.json +6 -6
  23. package/r/maquette-viewer.json +7 -6
  24. package/r/markdown-editor.json +10 -9
  25. package/r/mockup-viewer.json +5 -5
  26. package/r/page-header.json +22 -0
  27. package/r/pi-ambient.json +12 -12
  28. package/r/pi-app-kinds.json +10 -10
  29. package/r/pi-app.json +17 -17
  30. package/r/pi-artifacts.json +4 -4
  31. package/r/pi-chat-approval.json +4 -4
  32. package/r/pi-chat-attachments.json +4 -4
  33. package/r/pi-chat-markdown.json +3 -3
  34. package/r/pi-chat-mentions.json +4 -4
  35. package/r/pi-chat-pickers.json +4 -4
  36. package/r/pi-chat-plugins.json +11 -11
  37. package/r/pi-chat-question.json +3 -3
  38. package/r/pi-chat-slash.json +5 -5
  39. package/r/pi-chat-subagent.json +4 -4
  40. package/r/pi-chat-voice.json +7 -7
  41. package/r/pi-chat.json +20 -20
  42. package/r/pi-workspace.json +3 -3
  43. package/r/placed-agent.json +7 -7
  44. package/r/provider-setup.json +4 -4
  45. package/r/record-viewer.json +5 -3
  46. package/r/registry.json +494 -280
  47. package/r/shared-chat.json +11 -11
  48. package/r/spaces.json +7 -5
  49. package/r/task-list-viewer.json +28 -3
  50. package/r/theme.json +115 -50
  51. package/r/version-history.json +2 -2
  52. package/r/viewer-code.json +5 -5
  53. package/r/viewer-frame.json +9 -9
  54. package/r/viewer-html.json +7 -7
  55. package/r/viewer-image.json +5 -5
  56. package/r/viewer-markdown.json +7 -7
  57. package/r/viewer-pdf.json +6 -6
  58. package/r/viewers.json +6 -6
  59. package/r/windows.json +4 -4
@@ -4,16 +4,16 @@
4
4
  "title": "Pi chat model and effort picker",
5
5
  "description": "The composer's one model-and-effort pill. Plug it in with `model={{ ...config, Picker: ModelEffortPicker }}` (or on `effort`); without it pi-chat shows no pill.",
6
6
  "dependencies": [
7
- "react@^19.0.0",
8
- "lucide-react@^1.51.0"
7
+ "react@^19.0.0"
9
8
  ],
10
9
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
12
12
  ],
13
13
  "files": [
14
14
  {
15
15
  "path": "registry/pi-chat-pickers/pickers.tsx",
16
- "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { ChevronDownIcon, Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport type { ModelEffortPickerProps, ModelRef } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\ninterface Choice { readonly section: 'model' | 'effort'; readonly value: string; readonly label: string; readonly detail?: string }\n\nconst key = (model: ModelRef) => `${model.provider}/${model.modelId}`;\n\n/**\n * One pill for the model and the thinking level (\"GPT-5 mini · Medium\") that opens the host's shadcn dropdown menu with a radio\n * group for each. Only the configured parts show; with neither there is no pill.\n */\nexport function ModelEffortPicker({ model, effort, currentModel, currentEffort, disabled, busy, onModel, onEffort }: ModelEffortPickerProps) {\n const { labels } = useChatText();\n const name = labels.effortName;\n const [open, setOpen] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the menu closes when the picker becomes disabled (a run starts), once per change\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const models: Choice[] = (model?.options ?? []).map(option => ({ section: 'model', value: key(option), label: option.label ?? option.modelId, detail: option.provider }));\n // A model or level outside the offered list is still shown, so the label never lies about what runs.\n if (model && currentModel && !models.some(choice => choice.value === key(currentModel))) models.unshift({ section: 'model', value: key(currentModel), label: currentModel.modelId, detail: currentModel.provider });\n const levels: Choice[] = (effort?.options ?? []).map(level => ({ section: 'effort', value: level, label: name(level), ...(labels.effortDetail(level) ? { detail: labels.effortDetail(level)! } : {}) }));\n if (effort && currentEffort && !levels.some(choice => choice.value === currentEffort)) levels.unshift({ section: 'effort', value: currentEffort, label: name(currentEffort) });\n if (!models.length && !levels.length) return null;\n const modelValue = currentModel ? key(currentModel) : '';\n\n const pickModel = (value: string) => {\n const option = model?.options.find(item => key(item) === value);\n if (option && value !== modelValue) onModel({ provider: option.provider, modelId: option.modelId });\n };\n const modelLabel = model ? models.find(choice => choice.value === modelValue)?.label || currentModel?.modelId || labels.model : undefined;\n const effortLabel = effort ? currentEffort ? name(currentEffort) : labels.effortDefault : undefined;\n const summary = [modelLabel, effortLabel].filter(Boolean).join(' · ');\n const description = labels.modelSummary(modelLabel, effortLabel);\n const section = (title: string, items: readonly Choice[], value: string, pick: (value: string) => void) => items.length > 0 && <DropdownMenuGroup>\n <DropdownMenuLabel>{title}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={value} onValueChange={next => pick(String(next))}>\n {items.map(choice => <DropdownMenuRadioItem key={choice.value} value={choice.value} data-testid={`composer-${choice.section}-option`} data-value={choice.value} closeOnClick className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <span className=\"flex min-w-0 flex-1 flex-col\"><span className=\"truncate\">{choice.label}</span>\n {choice.detail && <span className=\"truncate text-xs text-muted-foreground\">{choice.detail}</span>}</span>\n </DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>;\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <div className=\"min-w-0 max-w-[65%] shrink @max-[480px]/composer:max-w-20\">\n <DropdownMenuTrigger render={<Button variant=\"ghost\" />} data-testid=\"composer-model\" data-value={modelValue} data-effort={currentEffort ?? ''} disabled={disabled} aria-label={description} title={description} className=\"min-w-0 max-w-full\">\n {busy && <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span data-testid=\"composer-model-label\" className=\"min-w-0 truncate\">{summary}</span>\n <ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n </div>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.modelAndEffort} data-testid=\"composer-model-menu\" className=\"w-56 max-w-[calc(100vw-1rem)]\">\n {model && section(labels.model, models, modelValue, pickModel)}\n {model && effort && models.length > 0 && levels.length > 0 && <DropdownMenuSeparator />}\n {effort && section(labels.effort, levels, currentEffort ?? '', value => { if (value !== currentEffort) onEffort(value); })}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
16
+ "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { ChevronDownIcon, Loader2Icon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport type { ModelEffortPickerProps, ModelRef } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\ninterface Choice { readonly section: 'model' | 'effort'; readonly value: string; readonly label: string; readonly detail?: string }\n\nconst key = (model: ModelRef) => `${model.provider}/${model.modelId}`;\n\n/**\n * One pill for the model and the thinking level (\"GPT-5 mini · Medium\") that opens the host's shadcn dropdown menu with a radio\n * group for each. Only the configured parts show; with neither there is no pill.\n */\nexport function ModelEffortPicker({ model, effort, currentModel, currentEffort, disabled, busy, onModel, onEffort }: ModelEffortPickerProps) {\n const { labels } = useChatText();\n const name = labels.effortName;\n const [open, setOpen] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the menu closes when the picker becomes disabled (a run starts), once per change\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const models: Choice[] = (model?.options ?? []).map(option => ({ section: 'model', value: key(option), label: option.label ?? option.modelId, detail: option.provider }));\n // A model or level outside the offered list is still shown, so the label never lies about what runs.\n if (model && currentModel && !models.some(choice => choice.value === key(currentModel))) models.unshift({ section: 'model', value: key(currentModel), label: currentModel.modelId, detail: currentModel.provider });\n const levels: Choice[] = (effort?.options ?? []).map(level => ({ section: 'effort', value: level, label: name(level), ...(labels.effortDetail(level) ? { detail: labels.effortDetail(level)! } : {}) }));\n if (effort && currentEffort && !levels.some(choice => choice.value === currentEffort)) levels.unshift({ section: 'effort', value: currentEffort, label: name(currentEffort) });\n if (!models.length && !levels.length) return null;\n const modelValue = currentModel ? key(currentModel) : '';\n\n const pickModel = (value: string) => {\n const option = model?.options.find(item => key(item) === value);\n if (option && value !== modelValue) onModel({ provider: option.provider, modelId: option.modelId });\n };\n const modelLabel = model ? models.find(choice => choice.value === modelValue)?.label || currentModel?.modelId || labels.model : undefined;\n const effortLabel = effort ? currentEffort ? name(currentEffort) : labels.effortDefault : undefined;\n const summary = [modelLabel, effortLabel].filter(Boolean).join(' · ');\n const description = labels.modelSummary(modelLabel, effortLabel);\n const section = (title: string, items: readonly Choice[], value: string, pick: (value: string) => void) => items.length > 0 && <DropdownMenuGroup>\n <DropdownMenuLabel>{title}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={value} onValueChange={next => pick(String(next))}>\n {items.map(choice => <DropdownMenuRadioItem key={choice.value} value={choice.value} data-testid={`composer-${choice.section}-option`} data-value={choice.value} closeOnClick className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <span className=\"flex min-w-0 flex-1 flex-col\"><span className=\"truncate\">{choice.label}</span>\n {choice.detail && <span className=\"truncate text-xs text-muted-foreground\">{choice.detail}</span>}</span>\n </DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>;\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <div className=\"min-w-0 max-w-[65%] shrink @max-[480px]/composer:max-w-20\">\n <DropdownMenuTrigger render={<Button variant=\"ghost\" />} data-testid=\"composer-model\" data-value={modelValue} data-effort={currentEffort ?? ''} disabled={disabled} aria-label={description} title={description} className=\"min-w-0 max-w-full\">\n {busy && <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span data-testid=\"composer-model-label\" className=\"min-w-0 truncate\">{summary}</span>\n <ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n </div>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.modelAndEffort} data-testid=\"composer-model-menu\" className=\"w-56 max-w-[calc(100vw-1rem)]\">\n {model && section(labels.model, models, modelValue, pickModel)}\n {model && effort && models.length > 0 && levels.length > 0 && <DropdownMenuSeparator />}\n {effort && section(labels.effort, levels, currentEffort ?? '', value => { if (value !== currentEffort) onEffort(value); })}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
17
17
  "type": "registry:component",
18
18
  "target": "components/pi-chat-pickers/pickers.tsx"
19
19
  }
@@ -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.57/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-markdown.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-slash.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-mentions.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-pickers.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-voice.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-attachments.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-approval.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat-subagent.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-artifacts.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-markdown.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-slash.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-mentions.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-pickers.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-voice.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-attachments.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-approval.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-subagent.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-artifacts.json"
21
21
  ],
22
22
  "files": [
23
23
  {
@@ -5,16 +5,16 @@
5
5
  "description": "The human-in-the-loop question card for `ask_user` (choices, free text, answered state). Plug it in with `cards={{ question: QuestionCard }}`; without it pi-chat shows a \"waiting for your answer\" notice with no answer UI.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
- "lucide-react@^1.51.0",
9
8
  "@earendil-works/pi-ai@~1.4.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "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 'lucide-react';\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",
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
18
  "type": "registry:component",
19
19
  "target": "components/pi-chat-question/question-card.tsx"
20
20
  }
@@ -4,17 +4,17 @@
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.57",
8
- "react@^19.0.0",
9
- "lucide-react@^1.51.0"
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "react@^19.0.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "path": "registry/pi-chat-slash/slash-menu.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { SearchIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Item, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from '../pi-chat/picker-keyboard';\nimport { ComposerSuggestions } from '../pi-chat/suggestions';\nimport { slashGroup } from '@hachej/boring-ui-kit/chat-text';\nimport type { SlashPickerProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\nconst ALL = '__all__';\n\n/**\n * The `/` picker, ported from v2's `SlashCommandPicker`: a search box seeded from what was typed after `/`, filter chips\n * (All, built-in, skills, plugin names), then rows with a name, badges and a one line description.\n */\nexport function SlashPicker({ query, items, onSelect, onDismiss }: SlashPickerProps) {\n const { labels } = useChatText();\n const [group, setGroup] = useState(ALL);\n const [search, setSearch] = useState(query);\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n // Follow the composer text.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the search box follows the composer's query when it changes, and stays editable in between\n useEffect(() => { setSearch(query); setActive(0); }, [query]);\n const groups = useMemo(() => [...new Set(items.map(slashGroup))].sort((a, b) => a.localeCompare(b)), [items]);\n const filtered = useMemo(() => {\n const needle = search.trim().toLowerCase();\n return items.filter(item => (group === ALL || slashGroup(item) === group)\n && (!needle || item.name.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle)));\n }, [items, search, group]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a shorter list pulls the highlighted row back inside it; the updater returns the same index when it already fits\n useEffect(() => { setActive(index => filtered.length === 0 ? 0 : Math.min(index, filtered.length - 1)); }, [filtered.length]);\n usePickerKeyboard({ count: filtered.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = filtered[index]; if (item) onSelect(item); }, onDismiss });\n\n const optionId = (index: number) => `pi-chat-slash-${index}`;\n return <ComposerSuggestions testId=\"slash-menu\" onDismiss={onDismiss}>\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" aria-label={labels.searchCommands} data-testid=\"slash-search\" type=\"text\" autoComplete=\"off\" spellCheck={false} value={search}\n onChange={event => { setSearch(event.currentTarget.value); setActive(0); }} placeholder={labels.searchCommands} />\n </InputGroup>\n <div role=\"tablist\" aria-label={labels.filterBySource} className=\"flex flex-wrap gap-1\">\n {[ALL, ...groups].map(name => <Button key={name} size=\"xs\" variant={group === name ? 'secondary' : 'ghost'} role=\"tab\" aria-selected={group === name} data-testid=\"slash-chip\" data-group={name === ALL ? 'all' : name}\n onMouseDown={event => { event.preventDefault(); setGroup(name); setActive(0); }}>{name === ALL ? labels.allSources : labels.slashGroup(name)}</Button>)}\n </div>\n {filtered.length === 0\n ? <div data-testid=\"slash-empty\" className=\"text-xs text-muted-foreground\">{labels.noCommands}</div>\n : <ul ref={list} role=\"listbox\" aria-label={labels.commands} className=\"m-0 flex max-h-[min(20rem,45dvh)] list-none flex-col overflow-y-auto p-0\">\n {filtered.map((item, index) => <Item key={`${item.source}:${item.name}`} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} id={optionId(index)} role=\"option\" aria-selected={index === active} title={item.description || undefined}\n data-testid=\"slash-item\" data-name={item.name} data-source={item.source} data-active={index === active ? 'true' : undefined}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n <ItemContent className=\"min-w-0\">\n <ItemTitle>/{item.name}\n {item.source === 'skill' && <Badge variant=\"secondary\" data-testid=\"slash-badge-skill\">{labels.skillBadge}</Badge>}\n {item.sourcePlugin && <Badge variant=\"outline\" data-testid=\"slash-badge-source\">{item.sourcePlugin}</Badge>}\n </ItemTitle>\n {item.description && <ItemDescription className=\"m-0 truncate\">{item.description}</ItemDescription>}\n </ItemContent>\n </Item>)}\n </ul>}\n </ComposerSuggestions>;\n}\n",
17
+ "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { SearchIcon } from '../_shared/icons';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Item, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from '../pi-chat/picker-keyboard';\nimport { ComposerSuggestions } from '../pi-chat/suggestions';\nimport { slashGroup } from '@hachej/boring-ui-kit/chat-text';\nimport type { SlashPickerProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\nconst ALL = '__all__';\n\n/**\n * The `/` picker, ported from v2's `SlashCommandPicker`: a search box seeded from what was typed after `/`, filter chips\n * (All, built-in, skills, plugin names), then rows with a name, badges and a one line description.\n */\nexport function SlashPicker({ query, items, onSelect, onDismiss }: SlashPickerProps) {\n const { labels } = useChatText();\n const [group, setGroup] = useState(ALL);\n const [search, setSearch] = useState(query);\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n // Follow the composer text.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the search box follows the composer's query when it changes, and stays editable in between\n useEffect(() => { setSearch(query); setActive(0); }, [query]);\n const groups = useMemo(() => [...new Set(items.map(slashGroup))].sort((a, b) => a.localeCompare(b)), [items]);\n const filtered = useMemo(() => {\n const needle = search.trim().toLowerCase();\n return items.filter(item => (group === ALL || slashGroup(item) === group)\n && (!needle || item.name.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle)));\n }, [items, search, group]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a shorter list pulls the highlighted row back inside it; the updater returns the same index when it already fits\n useEffect(() => { setActive(index => filtered.length === 0 ? 0 : Math.min(index, filtered.length - 1)); }, [filtered.length]);\n usePickerKeyboard({ count: filtered.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = filtered[index]; if (item) onSelect(item); }, onDismiss });\n\n const optionId = (index: number) => `pi-chat-slash-${index}`;\n return <ComposerSuggestions testId=\"slash-menu\" onDismiss={onDismiss}>\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" aria-label={labels.searchCommands} data-testid=\"slash-search\" type=\"text\" autoComplete=\"off\" spellCheck={false} value={search}\n onChange={event => { setSearch(event.currentTarget.value); setActive(0); }} placeholder={labels.searchCommands} />\n </InputGroup>\n <div role=\"tablist\" aria-label={labels.filterBySource} className=\"flex flex-wrap gap-1\">\n {[ALL, ...groups].map(name => <Button key={name} size=\"xs\" variant={group === name ? 'secondary' : 'ghost'} role=\"tab\" aria-selected={group === name} data-testid=\"slash-chip\" data-group={name === ALL ? 'all' : name}\n onMouseDown={event => { event.preventDefault(); setGroup(name); setActive(0); }}>{name === ALL ? labels.allSources : labels.slashGroup(name)}</Button>)}\n </div>\n {filtered.length === 0\n ? <div data-testid=\"slash-empty\" className=\"text-xs text-muted-foreground\">{labels.noCommands}</div>\n : <ul ref={list} role=\"listbox\" aria-label={labels.commands} className=\"m-0 flex max-h-[min(20rem,45dvh)] list-none flex-col overflow-y-auto p-0\">\n {filtered.map((item, index) => <Item key={`${item.source}:${item.name}`} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} id={optionId(index)} role=\"option\" aria-selected={index === active} title={item.description || undefined}\n data-testid=\"slash-item\" data-name={item.name} data-source={item.source} data-active={index === active ? 'true' : undefined}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n <ItemContent className=\"min-w-0\">\n <ItemTitle>/{item.name}\n {item.source === 'skill' && <Badge variant=\"secondary\" data-testid=\"slash-badge-skill\">{labels.skillBadge}</Badge>}\n {item.sourcePlugin && <Badge variant=\"outline\" data-testid=\"slash-badge-source\">{item.sourcePlugin}</Badge>}\n </ItemTitle>\n {item.description && <ItemDescription className=\"m-0 truncate\">{item.description}</ItemDescription>}\n </ItemContent>\n </Item>)}\n </ul>}\n </ComposerSuggestions>;\n}\n",
18
18
  "type": "registry:component",
19
19
  "target": "components/pi-chat-slash/slash-menu.tsx"
20
20
  }
@@ -5,22 +5,22 @@
5
5
  "description": "The card for a tool call that ran a sub-agent (`delegate`, and the run card of a started run). Plug it in with `cards={{ subagent: subagentCard }}`; without it the call is an ordinary tool step.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
- "lucide-react@^1.51.0",
9
8
  "@earendil-works/pi-ai@~1.4.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "path": "registry/pi-chat-subagent/run-card.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CheckIcon, CircleAlertIcon, RefreshCwIcon } from 'lucide-react';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\n\nexport type RunStatus = 'queued' | 'running' | 'completed' | 'failed';\n/** A headless run as the host's `GET _agent/<name>/runs/<id>` returns it. */\nexport interface RunRecord {\n readonly id: string;\n readonly status: RunStatus;\n /** The structured result: shown as text (`renderText`) when it is text, as JSON otherwise. */\n readonly output?: unknown;\n /** The run's final text, shown when there is no `output`. */\n readonly text?: string | undefined;\n readonly error?: string | undefined;\n /** The agent's title. */\n readonly agent?: string | undefined;\n /** Epoch milliseconds or an ISO date. */\n readonly startedAt?: number | string | undefined;\n readonly finishedAt?: number | string | undefined;\n}\n\nconst STATUSES: readonly RunStatus[] = ['queued', 'running', 'completed', 'failed'];\nconst time = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;\nconst optionalText = (value: unknown): string | undefined => typeof value === 'string' && value ? value : undefined;\n\n/** Reads a run record defensively (it comes over the network); undefined when it has no id or no known status. */\nexport function parseRun(value: unknown): RunRecord | undefined {\n if (value === null || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n const { id, status } = record;\n if ((typeof id !== 'string' || !id) || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)) return undefined;\n const startedAt = time(record['startedAt']), finishedAt = time(record['finishedAt']);\n return { id, status: status as RunStatus, ...(record['output'] === undefined || record['output'] === null ? {} : { output: record['output'] }),\n ...(optionalText(record['text']) ? { text: record['text'] as string } : {}), ...(optionalText(record['error']) ? { error: record['error'] as string } : {}),\n ...(optionalText(record['agent']) ? { agent: record['agent'] as string } : {}), ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min`. */\nconst span = (ms: number): string => {\n const seconds = Math.max(0, Math.round(ms / 1000));\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\n\n/**\n * A headless run (`POST _agent/<name>/runs` → `GET runs/<id>`) as a card: the agent's title, a status badge, a spinner while it is queued or\n * running with the elapsed time, then the duration and the output (or text, drawn by `renderText`: plain text, or `markdownText` of `pi-chat-markdown`), or the error. The host fetches: give `run` (a\n * record it keeps current), and optionally `fetchRun(id, signal)` to have the card poll every `pollMs` until the run settles and offer\n * Refresh. URLs are the host's (relative to its page). Render it from `renderTool` or `renderEntry`, or anywhere in the page.\n */\nconst plainRunText = ({ text }: ChatTextPart): ReactNode => <div data-testid=\"message-text\" className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport function RunCard({ run: given, fetchRun, pollMs = 2000, now: clock = Date.now, renderText = plainRunText }: {\n readonly run: RunRecord;\n readonly fetchRun?: ((id: string, signal: AbortSignal) => Promise<unknown>) | undefined;\n readonly pollMs?: number;\n /** Draws the run's text. Default: plain text. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const [fetched, setFetched] = useState<RunRecord | undefined>();\n const [failed, setFailed] = useState(false);\n const run = fetched?.id === given.id ? fetched : given;\n const settled = run.status === 'completed' || run.status === 'failed';\n const [now, setNow] = useState(clock);\n const latest = useRef(fetchRun);\n useLayoutEffect(() => { latest.current = fetchRun; });\n\n const refresh = (signal: AbortSignal) => latest.current?.(given.id, signal).then(value => {\n if (signal.aborted) return;\n const parsed = parseRun(value);\n setFailed(!parsed);\n if (parsed?.id === given.id) setFetched(parsed);\n }).catch(() => { if (!signal.aborted) setFailed(true); });\n const polling = Boolean(fetchRun) && !settled;\n // The latest refresh, so polling restarts only when it starts, stops, or the run or interval changes.\n const refresher = useRef(refresh);\n useLayoutEffect(() => { refresher.current = refresh; });\n useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresher.current(abort.signal);\n const timer = setInterval(() => { void refresher.current(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]);\n useEffect(() => {\n if (settled || run.startedAt === undefined) return;\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [settled, run.startedAt, clock]);\n\n const started = time(run.startedAt), finished = time(run.finishedAt);\n const timing = started === undefined ? undefined : finished !== undefined ? labels.runDuration(span(finished - started)) : settled ? undefined : labels.runElapsed(span(now - started));\n const body = run.output !== undefined ? (typeof run.output === 'string' ? renderText({ text: run.output, streaming: false }) : <CodeBlock code={JSON.stringify(run.output, null, 2)} language=\"json\" />)\n : run.text ? renderText({ text: run.text, streaming: false }) : undefined;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"run-card\" data-state={run.status} data-run-id={run.id} aria-label={run.agent ?? labels.run} aria-busy={!settled} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {settled ? run.status === 'completed' ? <CheckIcon aria-hidden=\"true\" /> : <CircleAlertIcon aria-hidden=\"true\" /> : <Spinner data-testid=\"run-spinner\" aria-label={labels.runStatus(run.status)} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none [overflow-wrap:anywhere]\">{run.agent ?? labels.run}</ItemTitle>\n <ItemDescription className=\"line-clamp-none flex flex-wrap items-center gap-2\">\n <Badge data-testid=\"run-status\" variant={run.status === 'failed' ? 'destructive' : run.status === 'completed' ? 'secondary' : 'outline'}>{labels.runStatus(run.status)}</Badge>\n {timing && <span data-testid=\"run-time\">{timing}</span>}\n </ItemDescription>\n </ItemContent>\n {fetchRun && <Button variant=\"ghost\" size=\"icon\" data-testid=\"run-refresh\" aria-label={labels.runRefresh} title={labels.runRefresh}\n onClick={() => { void refresh(new AbortController().signal); }}><RefreshCwIcon aria-hidden=\"true\" /></Button>}\n {(body || run.error || failed) && <ItemFooter className=\"flex-col items-stretch\">\n {body && <div data-testid=\"run-output\" className=\"min-w-0\">{body}</div>}\n {run.error && <Alert variant=\"destructive\" data-testid=\"run-error\"><AlertDescription>{run.error}</AlertDescription></Alert>}\n {failed && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.runNotLoaded}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
17
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CheckIcon, CircleAlertIcon, RefreshCwIcon } from '../_shared/icons';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\n\nexport type RunStatus = 'queued' | 'running' | 'completed' | 'failed';\n/** A headless run as the host's `GET _agent/<name>/runs/<id>` returns it. */\nexport interface RunRecord {\n readonly id: string;\n readonly status: RunStatus;\n /** The structured result: shown as text (`renderText`) when it is text, as JSON otherwise. */\n readonly output?: unknown;\n /** The run's final text, shown when there is no `output`. */\n readonly text?: string | undefined;\n readonly error?: string | undefined;\n /** The agent's title. */\n readonly agent?: string | undefined;\n /** Epoch milliseconds or an ISO date. */\n readonly startedAt?: number | string | undefined;\n readonly finishedAt?: number | string | undefined;\n}\n\nconst STATUSES: readonly RunStatus[] = ['queued', 'running', 'completed', 'failed'];\nconst time = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;\nconst optionalText = (value: unknown): string | undefined => typeof value === 'string' && value ? value : undefined;\n\n/** Reads a run record defensively (it comes over the network); undefined when it has no id or no known status. */\nexport function parseRun(value: unknown): RunRecord | undefined {\n if (value === null || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n const { id, status } = record;\n if ((typeof id !== 'string' || !id) || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)) return undefined;\n const startedAt = time(record['startedAt']), finishedAt = time(record['finishedAt']);\n return { id, status: status as RunStatus, ...(record['output'] === undefined || record['output'] === null ? {} : { output: record['output'] }),\n ...(optionalText(record['text']) ? { text: record['text'] as string } : {}), ...(optionalText(record['error']) ? { error: record['error'] as string } : {}),\n ...(optionalText(record['agent']) ? { agent: record['agent'] as string } : {}), ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min`. */\nconst span = (ms: number): string => {\n const seconds = Math.max(0, Math.round(ms / 1000));\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\n\n/**\n * A headless run (`POST _agent/<name>/runs` → `GET runs/<id>`) as a card: the agent's title, a status badge, a spinner while it is queued or\n * running with the elapsed time, then the duration and the output (or text, drawn by `renderText`: plain text, or `markdownText` of `pi-chat-markdown`), or the error. The host fetches: give `run` (a\n * record it keeps current), and optionally `fetchRun(id, signal)` to have the card poll every `pollMs` until the run settles and offer\n * Refresh. URLs are the host's (relative to its page). Render it from `renderTool` or `renderEntry`, or anywhere in the page.\n */\nconst plainRunText = ({ text }: ChatTextPart): ReactNode => <div data-testid=\"message-text\" className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport function RunCard({ run: given, fetchRun, pollMs = 2000, now: clock = Date.now, renderText = plainRunText }: {\n readonly run: RunRecord;\n readonly fetchRun?: ((id: string, signal: AbortSignal) => Promise<unknown>) | undefined;\n readonly pollMs?: number;\n /** Draws the run's text. Default: plain text. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const [fetched, setFetched] = useState<RunRecord | undefined>();\n const [failed, setFailed] = useState(false);\n const run = fetched?.id === given.id ? fetched : given;\n const settled = run.status === 'completed' || run.status === 'failed';\n const [now, setNow] = useState(clock);\n const latest = useRef(fetchRun);\n useLayoutEffect(() => { latest.current = fetchRun; });\n\n const refresh = (signal: AbortSignal) => latest.current?.(given.id, signal).then(value => {\n if (signal.aborted) return;\n const parsed = parseRun(value);\n setFailed(!parsed);\n if (parsed?.id === given.id) setFetched(parsed);\n }).catch(() => { if (!signal.aborted) setFailed(true); });\n const polling = Boolean(fetchRun) && !settled;\n // The latest refresh, so polling restarts only when it starts, stops, or the run or interval changes.\n const refresher = useRef(refresh);\n useLayoutEffect(() => { refresher.current = refresh; });\n useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresher.current(abort.signal);\n const timer = setInterval(() => { void refresher.current(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]);\n useEffect(() => {\n if (settled || run.startedAt === undefined) return;\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [settled, run.startedAt, clock]);\n\n const started = time(run.startedAt), finished = time(run.finishedAt);\n const timing = started === undefined ? undefined : finished !== undefined ? labels.runDuration(span(finished - started)) : settled ? undefined : labels.runElapsed(span(now - started));\n const body = run.output !== undefined ? (typeof run.output === 'string' ? renderText({ text: run.output, streaming: false }) : <CodeBlock code={JSON.stringify(run.output, null, 2)} language=\"json\" />)\n : run.text ? renderText({ text: run.text, streaming: false }) : undefined;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"run-card\" data-state={run.status} data-run-id={run.id} aria-label={run.agent ?? labels.run} aria-busy={!settled} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {settled ? run.status === 'completed' ? <CheckIcon aria-hidden=\"true\" /> : <CircleAlertIcon aria-hidden=\"true\" /> : <Spinner data-testid=\"run-spinner\" aria-label={labels.runStatus(run.status)} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none [overflow-wrap:anywhere]\">{run.agent ?? labels.run}</ItemTitle>\n <ItemDescription className=\"line-clamp-none flex flex-wrap items-center gap-2\">\n <Badge data-testid=\"run-status\" variant={run.status === 'failed' ? 'destructive' : run.status === 'completed' ? 'secondary' : 'outline'}>{labels.runStatus(run.status)}</Badge>\n {timing && <span data-testid=\"run-time\">{timing}</span>}\n </ItemDescription>\n </ItemContent>\n {fetchRun && <Button variant=\"ghost\" size=\"icon\" data-testid=\"run-refresh\" aria-label={labels.runRefresh} title={labels.runRefresh}\n onClick={() => { void refresh(new AbortController().signal); }}><RefreshCwIcon aria-hidden=\"true\" /></Button>}\n {(body || run.error || failed) && <ItemFooter className=\"flex-col items-stretch\">\n {body && <div data-testid=\"run-output\" className=\"min-w-0\">{body}</div>}\n {run.error && <Alert variant=\"destructive\" data-testid=\"run-error\"><AlertDescription>{run.error}</AlertDescription></Alert>}\n {failed && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.runNotLoaded}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
18
18
  "type": "registry:component",
19
19
  "target": "components/pi-chat-subagent/run-card.tsx"
20
20
  },
21
21
  {
22
22
  "path": "registry/pi-chat-subagent/subagent-card.tsx",
23
- "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, CheckIcon, CircleAlertIcon, CircleSlashIcon, ClockIcon, WrenchIcon } from 'lucide-react';\nimport { useChatText } from '../pi-chat/labels';\nimport type { SubagentRenderer } from '../pi-chat/config';\n\n/** `DELEGATE_DETAILS_KIND` of `@hachej/boring-agent/agents` (a contract test keeps them equal). */\nexport const DELEGATE_DETAILS_KIND = 'boring.delegate';\nexport type SubagentStatus = 'running' | 'done' | 'failed' | 'cancelled';\n/** A delegate call's details (`DelegateDetails` of `@hachej/boring-agent/agents`, version 1) as the card reads them. */\nexport interface SubagentDetails {\n readonly agent: string;\n /** The child conversation (Pi's conversation id). */\n readonly conversationId: number;\n readonly status: SubagentStatus;\n /** Epoch milliseconds. */\n readonly startedAt: number;\n readonly durationMs: number;\n readonly toolCalls: number;\n readonly tokens: { readonly input: number; readonly output: number };\n readonly error?: string;\n}\n\nconst STATUSES: readonly SubagentStatus[] = ['running', 'done', 'failed', 'cancelled'];\nconst count = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;\n\n/**\n * Reads a delegate call's details defensively (they come from the conversation): undefined unless they are version 1\n * `DelegateDetails`. With the call's `result`, a value still `running` is settled from it: Pi writes the result of a stopped call\n * from the last running details, so a settled `running` reads as `cancelled` (or `failed` with the result's text when it is not\n * an abort), with the duration up to the result.\n */\nexport function parseDelegateDetails(value: unknown, result?: Pick<ToolResultMessage, 'isError' | 'timestamp' | 'content'>): SubagentDetails | undefined {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (record['kind'] !== DELEGATE_DETAILS_KIND || record['version'] !== 1) return undefined;\n const { agent, conversationId, status } = record;\n const tokens = record['tokens'] !== null && typeof record['tokens'] === 'object' ? record['tokens'] as Record<string, unknown> : {};\n const startedAt = count(record['startedAt']), durationMs = count(record['durationMs']), toolCalls = count(record['toolCalls']);\n const input = count(tokens['input']), output = count(tokens['output']);\n if (typeof agent !== 'string' || count(conversationId) === undefined || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)\n || startedAt === undefined || durationMs === undefined || toolCalls === undefined || input === undefined || output === undefined) return undefined;\n const error = typeof record['error'] === 'string' && record['error'] ? record['error'] : undefined;\n const details: SubagentDetails = { agent, conversationId: conversationId as number, status: status as SubagentStatus, startedAt, durationMs, toolCalls, tokens: { input, output }, ...(error ? { error } : {}) };\n if (!result || details.status !== 'running') return details;\n const text = result.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n').trim();\n const ended = Math.max(details.durationMs, typeof result.timestamp === 'number' ? result.timestamp - details.startedAt : 0);\n if (!result.isError) return { ...details, status: 'done', durationMs: ended };\n return /\\babort/i.test(text) || !text ? { ...details, status: 'cancelled', durationMs: ended } : { ...details, status: 'failed', durationMs: ended, error: text };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min` (under a second: `0.4 s`). */\nconst span = (ms: number): string => {\n if (ms < 1000) return `${(Math.max(0, ms) / 1000).toFixed(1)} s`;\n const seconds = Math.round(ms / 1000);\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\nconst compact = (value: number): string => value < 1000 ? String(value) : value < 1_000_000 ? `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k` : `${(value / 1_000_000).toFixed(1)}M`;\n\n/**\n * A sub-agent (`delegate`) call as a card: the child agent, its status, the time it has run, its tool calls and tokens, and the\n * child conversation's id; the error when it failed. The chat renders it for any tool call whose details are `DelegateDetails`,\n * live while the child runs. `onOpen(conversationId)` makes the id a button that opens the child conversation.\n */\nexport function SubagentCard({ details, onOpen, now: clock = Date.now }: {\n readonly details: SubagentDetails;\n readonly onOpen?: ((conversationId: number) => void) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const running = details.status === 'running';\n const [now, setNow] = useState(clock);\n useEffect(() => {\n if (!running) return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a run that starts (again) reads the clock at once, not a second later\n setNow(clock());\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [running, clock]);\n const elapsed = running ? Math.max(details.durationMs, now - details.startedAt) : details.durationMs;\n const conversation = labels.subagentConversation(details.conversationId);\n const status = labels.subagentStatus(details.status);\n const tokens = labels.subagentTokens(details.tokens.input, details.tokens.output);\n const calls = labels.subagentToolCalls(details.toolCalls);\n const time = `${labels.subagentTime}: ${span(elapsed)}`;\n return <Item variant=\"outline\" size=\"xs\" render={<section />} data-testid=\"subagent-card\" data-state={details.status} data-conversation-id={details.conversationId}\n aria-label={`${details.agent}, ${status}`} aria-busy={running} className=\"my-1 text-start\">\n <ItemMedia variant=\"icon\" data-testid=\"subagent-status\" role=\"img\" aria-label={status} title={status}>\n <span className={details.status === 'failed' ? 'contents text-destructive' : details.status === 'done' ? 'contents' : 'contents text-muted-foreground'}>\n {running ? <Spinner data-testid=\"subagent-spinner\" aria-hidden=\"true\" />\n : details.status === 'done' ? <CheckIcon aria-hidden=\"true\" /> : details.status === 'failed' ? <CircleAlertIcon aria-hidden=\"true\" /> : <CircleSlashIcon aria-hidden=\"true\" />}\n </span>\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5\">\n <ItemTitle className=\"min-w-0 [overflow-wrap:anywhere]\">{details.agent}</ItemTitle>\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0\">\n <span data-testid=\"subagent-time\" className=\"inline-flex items-center gap-1\" title={time} aria-label={time}><ClockIcon aria-hidden=\"true\" />{span(elapsed)}</span>\n <span data-testid=\"subagent-tool-calls\" data-count={details.toolCalls} className=\"inline-flex items-center gap-1\" title={calls} aria-label={calls}><WrenchIcon aria-hidden=\"true\" />{details.toolCalls}</span>\n <span data-testid=\"subagent-tokens\" className=\"inline-flex items-center gap-0.5\" title={tokens} aria-label={tokens}>\n <ArrowUpIcon aria-hidden=\"true\" />{compact(details.tokens.input)}<ArrowDownIcon aria-hidden=\"true\" className=\"ms-1\" />{compact(details.tokens.output)}\n </span>\n </span>\n </div>\n </ItemContent>\n <ItemActions>\n {onOpen\n ? <Button variant=\"ghost\" size=\"xs\" data-testid=\"subagent-open\" title={conversation} aria-label={conversation} onClick={() => onOpen(details.conversationId)}>\n #{details.conversationId}<ArrowUpRightIcon aria-hidden=\"true\" data-icon=\"inline-end\" />\n </Button>\n : <span data-testid=\"subagent-conversation\" className=\"text-xs text-muted-foreground tabular-nums\" title={conversation}>#{details.conversationId}</span>}\n </ItemActions>\n {details.status === 'failed' && details.error && <ItemFooter>\n <p data-testid=\"subagent-error\" role=\"alert\" className=\"m-0 ps-6 text-xs text-destructive [overflow-wrap:anywhere]\">{details.error}</p>\n </ItemFooter>}\n </Item>;\n}\n\n/**\n * The sub-agent card as the chat's `cards.subagent`: a call whose details (live from its running slot, or settled from its result) are\n * `DelegateDetails` becomes a `SubagentCard`. Any other call stays an ordinary tool step.\n */\nexport const subagentCard: SubagentRenderer = (_call, details, result) => {\n const parsed = parseDelegateDetails(details, result);\n return parsed ? <SubagentCard details={parsed} /> : undefined;\n};\n",
23
+ "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, CheckIcon, CircleAlertIcon, CircleSlashIcon, ClockIcon, WrenchIcon } from '../_shared/icons';\nimport { useChatText } from '../pi-chat/labels';\nimport type { SubagentRenderer } from '../pi-chat/config';\n\n/** `DELEGATE_DETAILS_KIND` of `@hachej/boring-agent/agents` (a contract test keeps them equal). */\nexport const DELEGATE_DETAILS_KIND = 'boring.delegate';\nexport type SubagentStatus = 'running' | 'done' | 'failed' | 'cancelled';\n/** A delegate call's details (`DelegateDetails` of `@hachej/boring-agent/agents`, version 1) as the card reads them. */\nexport interface SubagentDetails {\n readonly agent: string;\n /** The child conversation (Pi's conversation id). */\n readonly conversationId: number;\n readonly status: SubagentStatus;\n /** Epoch milliseconds. */\n readonly startedAt: number;\n readonly durationMs: number;\n readonly toolCalls: number;\n readonly tokens: { readonly input: number; readonly output: number };\n readonly error?: string;\n}\n\nconst STATUSES: readonly SubagentStatus[] = ['running', 'done', 'failed', 'cancelled'];\nconst count = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;\n\n/**\n * Reads a delegate call's details defensively (they come from the conversation): undefined unless they are version 1\n * `DelegateDetails`. With the call's `result`, a value still `running` is settled from it: Pi writes the result of a stopped call\n * from the last running details, so a settled `running` reads as `cancelled` (or `failed` with the result's text when it is not\n * an abort), with the duration up to the result.\n */\nexport function parseDelegateDetails(value: unknown, result?: Pick<ToolResultMessage, 'isError' | 'timestamp' | 'content'>): SubagentDetails | undefined {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (record['kind'] !== DELEGATE_DETAILS_KIND || record['version'] !== 1) return undefined;\n const { agent, conversationId, status } = record;\n const tokens = record['tokens'] !== null && typeof record['tokens'] === 'object' ? record['tokens'] as Record<string, unknown> : {};\n const startedAt = count(record['startedAt']), durationMs = count(record['durationMs']), toolCalls = count(record['toolCalls']);\n const input = count(tokens['input']), output = count(tokens['output']);\n if (typeof agent !== 'string' || count(conversationId) === undefined || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)\n || startedAt === undefined || durationMs === undefined || toolCalls === undefined || input === undefined || output === undefined) return undefined;\n const error = typeof record['error'] === 'string' && record['error'] ? record['error'] : undefined;\n const details: SubagentDetails = { agent, conversationId: conversationId as number, status: status as SubagentStatus, startedAt, durationMs, toolCalls, tokens: { input, output }, ...(error ? { error } : {}) };\n if (!result || details.status !== 'running') return details;\n const text = result.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n').trim();\n const ended = Math.max(details.durationMs, typeof result.timestamp === 'number' ? result.timestamp - details.startedAt : 0);\n if (!result.isError) return { ...details, status: 'done', durationMs: ended };\n return /\\babort/i.test(text) || !text ? { ...details, status: 'cancelled', durationMs: ended } : { ...details, status: 'failed', durationMs: ended, error: text };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min` (under a second: `0.4 s`). */\nconst span = (ms: number): string => {\n if (ms < 1000) return `${(Math.max(0, ms) / 1000).toFixed(1)} s`;\n const seconds = Math.round(ms / 1000);\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\nconst compact = (value: number): string => value < 1000 ? String(value) : value < 1_000_000 ? `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k` : `${(value / 1_000_000).toFixed(1)}M`;\n\n/**\n * A sub-agent (`delegate`) call as a card: the child agent, its status, the time it has run, its tool calls and tokens, and the\n * child conversation's id; the error when it failed. The chat renders it for any tool call whose details are `DelegateDetails`,\n * live while the child runs. `onOpen(conversationId)` makes the id a button that opens the child conversation.\n */\nexport function SubagentCard({ details, onOpen, now: clock = Date.now }: {\n readonly details: SubagentDetails;\n readonly onOpen?: ((conversationId: number) => void) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const running = details.status === 'running';\n const [now, setNow] = useState(clock);\n useEffect(() => {\n if (!running) return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a run that starts (again) reads the clock at once, not a second later\n setNow(clock());\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [running, clock]);\n const elapsed = running ? Math.max(details.durationMs, now - details.startedAt) : details.durationMs;\n const conversation = labels.subagentConversation(details.conversationId);\n const status = labels.subagentStatus(details.status);\n const tokens = labels.subagentTokens(details.tokens.input, details.tokens.output);\n const calls = labels.subagentToolCalls(details.toolCalls);\n const time = `${labels.subagentTime}: ${span(elapsed)}`;\n return <Item variant=\"outline\" size=\"xs\" render={<section />} data-testid=\"subagent-card\" data-state={details.status} data-conversation-id={details.conversationId}\n aria-label={`${details.agent}, ${status}`} aria-busy={running} className=\"my-1 text-start\">\n <ItemMedia variant=\"icon\" data-testid=\"subagent-status\" role=\"img\" aria-label={status} title={status}>\n <span className={details.status === 'failed' ? 'contents text-destructive' : details.status === 'done' ? 'contents' : 'contents text-muted-foreground'}>\n {running ? <Spinner data-testid=\"subagent-spinner\" aria-hidden=\"true\" />\n : details.status === 'done' ? <CheckIcon aria-hidden=\"true\" /> : details.status === 'failed' ? <CircleAlertIcon aria-hidden=\"true\" /> : <CircleSlashIcon aria-hidden=\"true\" />}\n </span>\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5\">\n <ItemTitle className=\"min-w-0 [overflow-wrap:anywhere]\">{details.agent}</ItemTitle>\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0\">\n <span data-testid=\"subagent-time\" className=\"inline-flex items-center gap-1\" title={time} aria-label={time}><ClockIcon aria-hidden=\"true\" />{span(elapsed)}</span>\n <span data-testid=\"subagent-tool-calls\" data-count={details.toolCalls} className=\"inline-flex items-center gap-1\" title={calls} aria-label={calls}><WrenchIcon aria-hidden=\"true\" />{details.toolCalls}</span>\n <span data-testid=\"subagent-tokens\" className=\"inline-flex items-center gap-0.5\" title={tokens} aria-label={tokens}>\n <ArrowUpIcon aria-hidden=\"true\" />{compact(details.tokens.input)}<ArrowDownIcon aria-hidden=\"true\" className=\"ms-1\" />{compact(details.tokens.output)}\n </span>\n </span>\n </div>\n </ItemContent>\n <ItemActions>\n {onOpen\n ? <Button variant=\"ghost\" size=\"xs\" data-testid=\"subagent-open\" title={conversation} aria-label={conversation} onClick={() => onOpen(details.conversationId)}>\n #{details.conversationId}<ArrowUpRightIcon aria-hidden=\"true\" data-icon=\"inline-end\" />\n </Button>\n : <span data-testid=\"subagent-conversation\" className=\"text-xs text-muted-foreground tabular-nums\" title={conversation}>#{details.conversationId}</span>}\n </ItemActions>\n {details.status === 'failed' && details.error && <ItemFooter>\n <p data-testid=\"subagent-error\" role=\"alert\" className=\"m-0 ps-6 text-xs text-destructive [overflow-wrap:anywhere]\">{details.error}</p>\n </ItemFooter>}\n </Item>;\n}\n\n/**\n * The sub-agent card as the chat's `cards.subagent`: a call whose details (live from its running slot, or settled from its result) are\n * `DelegateDetails` becomes a `SubagentCard`. Any other call stays an ordinary tool step.\n */\nexport const subagentCard: SubagentRenderer = (_call, details, result) => {\n const parsed = parseDelegateDetails(details, result);\n return parsed ? <SubagentCard details={parsed} /> : undefined;\n};\n",
24
24
  "type": "registry:component",
25
25
  "target": "components/pi-chat-subagent/subagent-card.tsx"
26
26
  }
@@ -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.57",
8
- "react@^19.0.0",
9
- "lucide-react@^1.51.0"
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "react@^19.0.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -27,13 +27,13 @@
27
27
  },
28
28
  {
29
29
  "path": "registry/pi-chat-voice/voice-message.tsx",
30
- "content": "'use client';\n\nimport { CircleIcon, MicIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { useChatText } from '../pi-chat/labels';\nimport { voiceClock } from '../pi-chat/config';\nimport type { PendingVoiceBubbleProps, VoiceMessageButtonProps } from '../pi-chat/config';\nimport { useVoiceMessage } from './use-voice-message';\n\nexport { voiceClock } from '../pi-chat/config';\n\n/** The same recording row for native chat and a shell-owned capture. It replaces the editable composer controls. */\nexport function RecordingBar({ voice, testId = 'voice-message-record' }: Pick<VoiceMessageButtonProps, 'voice' | 'testId'>) {\n const { labels, icons } = useChatText();\n return <div data-testid=\"voice-recording-bar\" data-state={voice.paused ? 'paused' : voice.phase} className=\"flex w-full min-w-0 items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={voice.cancel} className=\"min-h-11 min-w-11 shrink-0\"><Trash2Icon aria-hidden=\"true\" /></Button>\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <CircleIcon className=\"size-3 shrink-0 fill-current text-destructive\" aria-hidden=\"true\" />\n <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" data-testid=\"voice-recording\" className=\"shrink-0 tabular-nums\"><span className=\"sr-only\">{voice.paused ? labels.voicePaused : labels.voiceRecording} </span>{voiceClock(voice.seconds)}</span>\n {!!voice.levels?.length && <div data-testid=\"voice-waveform\" aria-hidden=\"true\" className=\"flex h-8 min-w-0 flex-1 items-center justify-end gap-0.5 overflow-hidden\">\n {voice.levels.map((level, index) => <span key={index} data-level={level} className=\"w-1 shrink-0 bg-muted-foreground\" style={{ height: `${Math.max(5, Math.sqrt(Math.min(1, level)) * 100)}%` }} />)}\n </div>}\n </div>\n {voice.pause && voice.resume && <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-pause\" aria-label={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} title={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} onClick={voice.paused ? voice.resume : voice.pause} className=\"min-h-11 min-w-11 shrink-0\">{voice.paused ? <PlayIcon aria-hidden=\"true\" /> : <PauseIcon aria-hidden=\"true\" />}</Button>}\n <Button variant=\"default\" size=\"icon\" data-testid={testId} data-state={voice.phase} aria-label={labels.send} title={labels.send} onClick={voice.finish} disabled={voice.phase === 'starting'} className=\"min-h-11 min-w-11 shrink-0\"><icons.send aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nexport function VoiceMessageButton({ voice, disabled, primary = false, testId = 'voice-message-record' }: VoiceMessageButtonProps) {\n const { labels } = useChatText();\n if (voice.phase === 'starting' || voice.phase === 'recording') return <RecordingBar voice={voice} testId={testId} />;\n return <Button onClick={voice.start} variant={primary ? 'default' : 'ghost'} size=\"icon\" className=\"min-h-11 min-w-11 shrink-0\" data-testid={testId} data-state={voice.phase} disabled={disabled || !voice.canStart || voice.phase !== 'idle'} aria-label={labels.voiceRecord} title={labels.voiceRecord}><MicIcon aria-hidden=\"true\" /></Button>;\n}\n\nexport function PendingVoiceBubble({ pending }: PendingVoiceBubbleProps) {\n const { labels } = useChatText();\n if (!pending) return null;\n const failed = pending.phase === 'transcribe-failed' || pending.phase === 'upload-failed';\n const blocked = pending.phase === 'blocked' || pending.phase === 'unconfirmed';\n return <Message align=\"end\" data-testid=\"voice-pending\" data-voice-id={pending.id} data-state={pending.phase}><MessageContent><Bubble variant=\"muted\" align=\"end\" className={failed || blocked ? 'w-full max-w-full' : undefined}><BubbleContent>\n <div role={failed ? 'alert' : 'status'} className=\"flex items-center gap-2\">\n {!failed && !blocked && <Spinner aria-hidden=\"true\" />}\n {Number.isFinite(pending.seconds) && pending.seconds > 0 && <span data-testid=\"voice-pending-duration\">{voiceClock(pending.seconds)}</span>}\n <span>{pending.phase === 'upload-failed' ? labels.uploadFailedShort : pending.phase === 'transcribe-failed' ? labels.voiceTranscribeFailed : pending.phase === 'blocked' ? labels.voiceBlocked : pending.phase === 'unconfirmed' ? labels.voiceUnconfirmed : labels.voicePreparing}</span>\n </div>\n {(failed || blocked) && <div className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2\">\n {failed && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-retry\" onClick={pending.retry}>{labels.retry}</Button>}\n {pending.audioOnly && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-audio-only\" onClick={pending.audioOnly}>{labels.voiceAudioOnly}</Button>}\n {pending.phase !== 'unconfirmed' && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"ghost\" data-testid=\"voice-discard\" onClick={pending.discard}>{labels.voiceDiscard}</Button>}\n </div>}\n </BubbleContent></Bubble></MessageContent></Message>;\n}\n\n/** Tap-to-record voice messages for the chat: `voiceMessage={{ ...config, ...voiceMessagePlugin }}`. */\nexport const voiceMessagePlugin = { useRecorder: useVoiceMessage, Button: VoiceMessageButton, Bubble: PendingVoiceBubble } as const;\n",
30
+ "content": "'use client';\n\nimport { CircleIcon, MicIcon, PauseIcon, PlayIcon, Trash2Icon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { useChatText } from '../pi-chat/labels';\nimport { voiceClock } from '../pi-chat/config';\nimport type { PendingVoiceBubbleProps, VoiceMessageButtonProps } from '../pi-chat/config';\nimport { useVoiceMessage } from './use-voice-message';\n\nexport { voiceClock } from '../pi-chat/config';\n\n/** The same recording row for native chat and a shell-owned capture. It replaces the editable composer controls. */\nexport function RecordingBar({ voice, testId = 'voice-message-record' }: Pick<VoiceMessageButtonProps, 'voice' | 'testId'>) {\n const { labels, icons } = useChatText();\n return <div data-testid=\"voice-recording-bar\" data-state={voice.paused ? 'paused' : voice.phase} className=\"flex w-full min-w-0 items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={voice.cancel} className=\"min-h-11 min-w-11 shrink-0\"><Trash2Icon aria-hidden=\"true\" /></Button>\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <CircleIcon className=\"size-3 shrink-0 fill-current text-destructive\" aria-hidden=\"true\" />\n <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" data-testid=\"voice-recording\" className=\"shrink-0 tabular-nums\"><span className=\"sr-only\">{voice.paused ? labels.voicePaused : labels.voiceRecording} </span>{voiceClock(voice.seconds)}</span>\n {!!voice.levels?.length && <div data-testid=\"voice-waveform\" aria-hidden=\"true\" className=\"flex h-8 min-w-0 flex-1 items-center justify-end gap-0.5 overflow-hidden\">\n {voice.levels.map((level, index) => <span key={index} data-level={level} className=\"w-1 shrink-0 bg-muted-foreground\" style={{ height: `${Math.max(5, Math.sqrt(Math.min(1, level)) * 100)}%` }} />)}\n </div>}\n </div>\n {voice.pause && voice.resume && <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-pause\" aria-label={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} title={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} onClick={voice.paused ? voice.resume : voice.pause} className=\"min-h-11 min-w-11 shrink-0\">{voice.paused ? <PlayIcon aria-hidden=\"true\" /> : <PauseIcon aria-hidden=\"true\" />}</Button>}\n <Button variant=\"default\" size=\"icon\" data-testid={testId} data-state={voice.phase} aria-label={labels.send} title={labels.send} onClick={voice.finish} disabled={voice.phase === 'starting'} className=\"min-h-11 min-w-11 shrink-0\"><icons.send aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nexport function VoiceMessageButton({ voice, disabled, primary = false, testId = 'voice-message-record' }: VoiceMessageButtonProps) {\n const { labels } = useChatText();\n if (voice.phase === 'starting' || voice.phase === 'recording') return <RecordingBar voice={voice} testId={testId} />;\n return <Button onClick={voice.start} variant={primary ? 'default' : 'ghost'} size=\"icon\" className=\"min-h-11 min-w-11 shrink-0\" data-testid={testId} data-state={voice.phase} disabled={disabled || !voice.canStart || voice.phase !== 'idle'} aria-label={labels.voiceRecord} title={labels.voiceRecord}><MicIcon aria-hidden=\"true\" /></Button>;\n}\n\nexport function PendingVoiceBubble({ pending }: PendingVoiceBubbleProps) {\n const { labels } = useChatText();\n if (!pending) return null;\n const failed = pending.phase === 'transcribe-failed' || pending.phase === 'upload-failed';\n const blocked = pending.phase === 'blocked' || pending.phase === 'unconfirmed';\n return <Message align=\"end\" data-testid=\"voice-pending\" data-voice-id={pending.id} data-state={pending.phase}><MessageContent><Bubble variant=\"muted\" align=\"end\" className={failed || blocked ? 'w-full max-w-full' : undefined}><BubbleContent>\n <div role={failed ? 'alert' : 'status'} className=\"flex items-center gap-2\">\n {!failed && !blocked && <Spinner aria-hidden=\"true\" />}\n {Number.isFinite(pending.seconds) && pending.seconds > 0 && <span data-testid=\"voice-pending-duration\">{voiceClock(pending.seconds)}</span>}\n <span>{pending.phase === 'upload-failed' ? labels.uploadFailedShort : pending.phase === 'transcribe-failed' ? labels.voiceTranscribeFailed : pending.phase === 'blocked' ? labels.voiceBlocked : pending.phase === 'unconfirmed' ? labels.voiceUnconfirmed : labels.voicePreparing}</span>\n </div>\n {(failed || blocked) && <div className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2\">\n {failed && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-retry\" onClick={pending.retry}>{labels.retry}</Button>}\n {pending.audioOnly && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-audio-only\" onClick={pending.audioOnly}>{labels.voiceAudioOnly}</Button>}\n {pending.phase !== 'unconfirmed' && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"ghost\" data-testid=\"voice-discard\" onClick={pending.discard}>{labels.voiceDiscard}</Button>}\n </div>}\n </BubbleContent></Bubble></MessageContent></Message>;\n}\n\n/** Tap-to-record voice messages for the chat: `voiceMessage={{ ...config, ...voiceMessagePlugin }}`. */\nexport const voiceMessagePlugin = { useRecorder: useVoiceMessage, Button: VoiceMessageButton, Bubble: PendingVoiceBubble } as const;\n",
31
31
  "type": "registry:component",
32
32
  "target": "components/pi-chat-voice/voice-message.tsx"
33
33
  },
34
34
  {
35
35
  "path": "registry/pi-chat-voice/voice-input.tsx",
36
- "content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useDictation } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { useChatText } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes); the person still finishes before text is appended. */\n readonly maxMs?: number | undefined;\n}\n\nexport function VoiceInput({ controller, transcribe, maxMs }: VoiceInputProps) {\n const { labels } = useChatText();\n const { phase, problem, start, finish, cancel } = useDictation({ transcribe, labels, maxMs, target: controller,\n onText: (spoken, signal) => {\n if (!signal.aborted) controller.setText(appendToDraft(controller.getSnapshot().draft.text, spoken));\n },\n });\n const onClick = () => { if (phase === 'idle') start(); else finish(); };\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(phase === 'starting' || recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord}\n title={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord} disabled={busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
36
+ "content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useDictation } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from '../_shared/icons';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { useChatText } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes); the person still finishes before text is appended. */\n readonly maxMs?: number | undefined;\n}\n\nexport function VoiceInput({ controller, transcribe, maxMs }: VoiceInputProps) {\n const { labels } = useChatText();\n const { phase, problem, start, finish, cancel } = useDictation({ transcribe, labels, maxMs, target: controller,\n onText: (spoken, signal) => {\n if (!signal.aborted) controller.setText(appendToDraft(controller.getSnapshot().draft.text, spoken));\n },\n });\n const onClick = () => { if (phase === 'idle') start(); else finish(); };\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(phase === 'starting' || recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord}\n title={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord} disabled={busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
37
37
  "type": "registry:component",
38
38
  "target": "components/pi-chat-voice/voice-input.tsx"
39
39
  }