@hachej/boring-ui-kit 0.4.25 → 0.4.27

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 (101) hide show
  1. package/README.md +40 -3
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-dictation.d.ts +27 -0
  5. package/dist/chat-dictation.js +71 -0
  6. package/dist/chat-react-controller.d.ts +25 -0
  7. package/dist/chat-react-controller.js +72 -0
  8. package/dist/chat-react-conversations.d.ts +40 -0
  9. package/dist/chat-react-conversations.js +68 -0
  10. package/dist/chat-react-draft.d.ts +11 -0
  11. package/dist/chat-react-draft.js +91 -0
  12. package/dist/chat-react-remote.d.ts +33 -0
  13. package/dist/chat-react-remote.js +72 -0
  14. package/dist/chat-react-session.d.ts +170 -0
  15. package/dist/chat-react-session.js +367 -0
  16. package/dist/chat-react-transcript.d.ts +39 -0
  17. package/dist/chat-react-transcript.js +123 -0
  18. package/dist/chat-react.d.ts +62 -0
  19. package/dist/chat-react.js +95 -0
  20. package/dist/chat-text.d.ts +131 -0
  21. package/dist/chat-text.js +220 -0
  22. package/dist/chat-view.d.ts +2 -17
  23. package/dist/chat-view.js +1 -0
  24. package/dist/chat-voice-message.d.ts +65 -0
  25. package/dist/chat-voice-message.js +235 -0
  26. package/dist/chat-voice.d.ts +7 -0
  27. package/dist/chat-voice.js +4 -0
  28. package/dist/experience-catalog.d.ts +2 -2
  29. package/dist/experience-region-tree.d.ts +1 -1
  30. package/dist/markdown-editor-labels.d.ts +83 -0
  31. package/dist/markdown-editor-labels.js +75 -0
  32. package/dist/markdown-editor.d.ts +7 -1
  33. package/dist/markdown-editor.js +28 -18
  34. package/dist/markdown-extensions.d.ts +4 -2
  35. package/dist/markdown-extensions.js +2 -2
  36. package/dist/markdown-preserved.d.ts +5 -1
  37. package/dist/markdown-preserved.js +15 -4
  38. package/dist/markdown-proposal-diff.d.ts +3 -1
  39. package/dist/markdown-proposal-diff.js +6 -5
  40. package/dist/run-notifications.d.ts +42 -0
  41. package/dist/run-notifications.js +67 -0
  42. package/dist/shared-chat/index.d.ts +1 -1
  43. package/dist/shared-chat/index.js +5 -4
  44. package/dist/text-draft-controls.d.ts +5 -1
  45. package/dist/text-draft-controls.js +6 -5
  46. package/dist/text-draft-labels.d.ts +26 -0
  47. package/dist/text-draft-labels.js +47 -0
  48. package/dist/voice-attempt.d.ts +33 -0
  49. package/dist/voice-attempt.js +122 -0
  50. package/dist/voice-capture.d.ts +12 -0
  51. package/dist/voice-capture.js +17 -2
  52. package/package.json +14 -2
  53. package/r/agent-target.json +5 -9
  54. package/r/app-shell.json +19 -28
  55. package/r/board-viewer.json +8 -11
  56. package/r/button.json +2 -9
  57. package/r/diagram-viewer.json +0 -5
  58. package/r/dock.json +2 -2
  59. package/r/documents.json +13 -18
  60. package/r/feedback-preview.json +1 -7
  61. package/r/feedback.json +12 -19
  62. package/r/file-tree.json +4 -14
  63. package/r/highlights.json +4 -9
  64. package/r/html-viewer.json +3 -3
  65. package/r/looks-viewer.json +3 -6
  66. package/r/maquette-viewer.json +2 -20
  67. package/r/markdown-editor.json +4 -7
  68. package/r/mockup-viewer.json +2 -5
  69. package/r/pi-ambient.json +11 -19
  70. package/r/pi-app-kinds.json +10 -10
  71. package/r/pi-app.json +17 -27
  72. package/r/pi-artifacts.json +3 -4
  73. package/r/pi-chat-approval.json +2 -5
  74. package/r/pi-chat-attachments.json +2 -4
  75. package/r/pi-chat-markdown.json +3 -5
  76. package/r/pi-chat-mentions.json +2 -3
  77. package/r/pi-chat-pickers.json +2 -4
  78. package/r/pi-chat-plugins.json +11 -11
  79. package/r/pi-chat-question.json +1 -7
  80. package/r/pi-chat-slash.json +3 -6
  81. package/r/pi-chat-subagent.json +3 -8
  82. package/r/pi-chat-voice.json +7 -10
  83. package/r/pi-chat.json +25 -53
  84. package/r/pi-workspace.json +4 -7
  85. package/r/placed-agent.json +4 -5
  86. package/r/provider-setup.json +2 -8
  87. package/r/record-viewer.json +0 -9
  88. package/r/registry.json +182 -403
  89. package/r/requests-board.json +2 -10
  90. package/r/shared-chat.json +10 -24
  91. package/r/task-list-viewer.json +3 -9
  92. package/r/utils.json +14 -2
  93. package/r/version-history.json +3 -5
  94. package/r/viewer-code.json +2 -3
  95. package/r/viewer-frame.json +9 -17
  96. package/r/viewer-html.json +4 -5
  97. package/r/viewer-image.json +3 -4
  98. package/r/viewer-markdown.json +6 -8
  99. package/r/viewer-pdf.json +5 -7
  100. package/r/viewers.json +6 -6
  101. package/r/windows.json +3 -8
@@ -4,20 +4,19 @@
4
4
  "title": "Pi artifacts",
5
5
  "description": "Artifact descriptors, kinds and the artifact card that opens generated content in the host's panel. Plug it in with `artifacts={{ ...artifactCards, open, isOpen, kinds }}`; without it pi-chat offers no artifact. pi-app and the viewers import their artifact kinds and types from it.",
6
6
  "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.27",
7
8
  "react@^19.0.0",
8
9
  "lucide-react@^1.51.0",
9
10
  "@earendil-works/pi-ai@~1.2.0",
10
11
  "@earendil-works/pi-durable@~1.2.0"
11
12
  ],
12
13
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
14
- "button",
15
- "item"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
16
15
  ],
17
16
  "files": [
18
17
  {
19
18
  "path": "registry/pi-artifacts/artifact.ts",
20
- "content": "import type { ConversationView } from '@earendil-works/pi-durable';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ComponentType, ReactNode } from 'react';\nimport { defaultChatLabels } from '../pi-chat/labels';\nimport type { ChatArtifact, ChatArtifactsConfig } from '../pi-chat/config';\n\n/*\n * Artifacts in the chat: substantial content (a document, an HTML page, an SVG, code, a canvas) that the transcript shows as a\n * card and the host opens in its own panel. The descriptor and `parseArtifact` mirror `@hachej/boring-agent/artifacts` on purpose: this\n * is a copied registry item and cannot import that package (it carries Pi runtime code). Keep the two validators in step;\n * `test/contracts/pi-chat-source.test.mjs` checks that they accept and refuse the same values.\n */\nexport const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'] as const;\nexport type ArtifactType = typeof ARTIFACT_TYPES[number];\n\n/** Structural twin of the `ResourceLocator` in `@hachej/boring-files`. */\nexport interface ArtifactTarget {\n readonly resource: { readonly providerId: string; readonly path: string };\n readonly view: { readonly kind: 'published' } | { readonly kind: 'working'; readonly viewId: string };\n}\nexport interface ArtifactDescriptor {\n readonly schema: 'boring.artifact';\n readonly version: 1;\n /** Set by the artifact tools; the descriptor of `present` has none (the file path is its identity). */\n readonly id?: string;\n readonly title: string;\n readonly type: ArtifactType;\n readonly mediaType: string;\n readonly language?: string;\n readonly target: ArtifactTarget;\n /** The revision this message produced. */\n readonly revision: string;\n /** The 1-based version number of this artifact (artifact tools only). */\n readonly ordinal?: number;\n}\n\n/** What identifies an artifact across its versions: its id, or for a presented file its path. */\nexport const artifactKey = (artifact: Pick<ArtifactDescriptor, 'id' | 'target'>): string => artifact.id ?? artifact.target.resource.path;\n\n/**\n * What an artifact kind's renderer receives: the artifact and its text at the revision shown, the page's URL fragment without `#`\n * (a link such as `#screen=2` can open a place inside the artifact; the panel follows its changes), and `read`: another file of the same\n * workspace as text (its latest revision; undefined when it is missing or not text), for an artifact that links to a file. `revision` is\n * the shown revision, the one a conditional save is made against; `pinned` is true when the panel shows a pinned historical version,\n * which is read-only.\n */\nexport interface ArtifactKindViewProps {\n readonly artifact: ArtifactDescriptor;\n readonly text: string;\n readonly revision: string;\n readonly pinned: boolean;\n /**\n * The view is loaded and on screen. A kind with `waitsForReady` must call it (once per revision shown): only then does the page\n * acknowledge a `present` call for this file. Other kinds are acknowledged as soon as their file is read, and may ignore it.\n */\n readonly onReady?: (() => void) | undefined;\n /**\n * The view could not be shown (its data failed to load, for example), with a short reason for the agent (at most 200 characters are\n * sent). A `present` call waiting for this file is refused with that reason. A renderer that throws is reported the same way.\n */\n readonly onFailed?: ((reason: string) => void) | undefined;\n readonly fragment?: string | undefined;\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n /**\n * The panel's bar owns the one \"…\" menu: a view publishes its own entries there (a maquette's guided tours) and clears them with `[]`.\n * Absent outside the panel; the view then draws its own menu.\n */\n readonly setMenu?: ((items: readonly ArtifactMenuEntry[]) => void) | undefined;\n /** The panel bar's controls slot: a view puts its own controls there (the SVG preview and source toggle) and clears them with undefined. */\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\n/** One entry a kind's view adds to the panel bar's \"…\" menu (structurally the viewer frame's menu item). */\nexport interface ArtifactMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\n\n/** What a kind's `load` resolves to: a module whose default export is the view. */\nexport type ArtifactKindView = { readonly default: ComponentType<ArtifactKindViewProps> };\n\n/**\n * An app's own kind of artifact (for example `.maquette.json` files): how its card and the panel name it, its icon, and optionally\n * how the panel draws it. Kinds are consulted in order before the built-in types; the first whose `match` is true wins. A kind\n * adds a presentation only: the descriptor, its type and how it is read are unchanged.\n */\nexport interface ArtifactKind {\n /** True for the artifacts of this kind, for example `target => target.resource.path.endsWith('.maquette.json')`. */\n readonly match: (target: ArtifactTarget, artifact: ArtifactDescriptor) => boolean;\n /** The kind's name on the card and in the panel bar (\"Maquette\"). The type's name otherwise. */\n readonly label?: string | undefined;\n /** The card's icon (any component taking `className`, such as a lucide icon). The type's icon otherwise. */\n readonly icon?: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }> | undefined;\n /** Draws the artifact in the panel (`AgentWorkspace`, `ArtifactPanel`) inside the standard viewer bar, with copy and download. */\n readonly renderer?: ((props: ArtifactKindViewProps) => ReactNode) | undefined;\n /**\n * The view's module, loaded the first time an artifact of this kind opens: the panel wraps it once per kind in `React.lazy` with a\n * spinner in the bar, so the view's code (and its libraries) is not in the page's first bundle. Same props and bar as `renderer`; a kind\n * with both draws with `renderer`. Write it as `() => import('./my-view')` so a bundler splits the view into its own chunk.\n */\n readonly load?: (() => Promise<ArtifactKindView>) | undefined;\n /**\n * A default viewer for a whole type or media type (the built-in Markdown, HTML, SVG, CSV, image and PDF kinds): a host's `viewers`\n * entry for that type wins over it, as it did over the built-in viewers.\n */\n readonly fallback?: boolean | undefined;\n /**\n * The renderer loads asynchronously (a dashboard fetching its data, a sandboxed frame): the panel confirms a `present` of this file\n * only after the renderer calls `onReady`, and refuses it when it calls `onFailed` or throws. With no call, the call ends unconfirmed.\n */\n readonly waitsForReady?: boolean | undefined;\n}\n\n/** What every kind shares, whatever its view: the match, its name and icon. The panel's Markdown and HTML editor kinds are kinds too. */\nexport type KindMatch = Pick<ArtifactKind, 'match' | 'label' | 'icon'>;\n\n/** The first kind that matches the artifact; a `match` that throws counts as no match. */\nexport function artifactKind<Kind extends KindMatch>(artifact: ArtifactDescriptor, kinds: readonly Kind[] | undefined): Kind | undefined {\n return kinds?.find(kind => { try { return kind.match(artifact.target, artifact); } catch { return false; } });\n}\n\n/** What the host passes to `PiChat` to turn artifact tool results into cards. */\nexport interface ArtifactsConfig {\n /** The app's own artifact kinds (label, icon, renderer), consulted before the built-in types. */\n readonly kinds?: readonly KindMatch[] | undefined;\n /** The person clicked a card: show this artifact (and revision) in the host's panel. */\n readonly open: (artifact: ArtifactDescriptor) => void;\n /** True for the card whose artifact version is on display. */\n readonly isOpen?: (artifact: ArtifactDescriptor) => boolean;\n /** Recognise a descriptor in a successful tool result that does not carry one itself (for example a document save). */\n readonly detect?: (call: ToolCall, result: ToolResultMessage) => ArtifactDescriptor | undefined;\n /** How the chat finds a call's artifact (`recogniseArtifact`). With `Card`, it is what makes `PiChat` offer artifacts; `artifactCards` sets both. */\n readonly recognise?: ChatArtifactsConfig['recognise'];\n /** The card the chat draws (`PluggedArtifactCard`). */\n readonly Card?: ChatArtifactsConfig['Card'];\n}\n\nexport const PRESENT_TOOL = 'present';\n\n/** A `present` call with no result yet, while its conversation runs: its answer ID (`[assistantEntryId, callId]`) and the path it names. */\nexport interface PendingPresent { readonly id: string; readonly callId: string; readonly path: string }\n\n/** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */\nexport const presentPath = (path: string): string => path.replace(/^\\.\\//, '');\n\n/**\n * The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors\n * the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.\n */\nexport function pendingPresents(view: { readonly entries: readonly { readonly id: number | bigint | string; readonly model?: readonly unknown[] | undefined }[]; readonly docs?: Readonly<Record<string, unknown>> } | undefined): readonly PendingPresent[] {\n if (!view) return [];\n const live = view.docs?.['pi.live'] as { readonly run?: unknown } | undefined;\n if (!live?.run) return [];\n const calls = new Map<string, PendingPresent>();\n for (const entry of view.entries) for (const message of entry.model ?? []) {\n const item = message as { readonly role?: string; readonly content?: unknown; readonly toolCallId?: string };\n if (item.role === 'assistant' && Array.isArray(item.content)) {\n for (const part of item.content as { type?: string; id?: string; name?: string; arguments?: Record<string, unknown> }[]) {\n const path = part.arguments?.['path'];\n if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string' && typeof path === 'string') {\n calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, path: presentPath(path) });\n }\n }\n }\n if (item.role === 'toolResult' && typeof item.toolCallId === 'string') calls.delete(item.toolCallId);\n }\n return [...calls.values()];\n}\n\nconst ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;\nconst LANGUAGE = /^[A-Za-z0-9+#._-]{1,32}$/;\nconst isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);\nconst hasOnly = (value: Record<string, unknown>, keys: readonly string[]) => Object.keys(value).every(key => keys.includes(key));\n\n/** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */\nexport function parseArtifact(value: unknown): ArtifactDescriptor | undefined {\n if (!isObject(value) || !hasOnly(value, ['schema', 'version', 'id', 'title', 'type', 'mediaType', 'language', 'target', 'revision', 'ordinal'])) return undefined;\n const { id, title, type, mediaType, language, target, revision, ordinal } = value;\n if (value['schema'] !== 'boring.artifact' || value['version'] !== 1) return undefined;\n if (id !== undefined && (typeof id !== 'string' || !ID.test(id))) return undefined;\n if (typeof title !== 'string' || !title.trim() || title.length > 200) return undefined;\n if (typeof type !== 'string' || !(ARTIFACT_TYPES as readonly string[]).includes(type)) return undefined;\n if (typeof mediaType !== 'string' || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(mediaType)) return undefined;\n if (language !== undefined && (typeof language !== 'string' || !LANGUAGE.test(language))) return undefined;\n if (typeof revision !== 'string' || !revision || revision.length > 256) return undefined;\n if (ordinal !== undefined && (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1)) return undefined;\n if (!isObject(target) || !hasOnly(target, ['resource', 'view']) || !isObject(target['resource']) || !hasOnly(target['resource'], ['providerId', 'path'])) return undefined;\n const { providerId, path } = target['resource'];\n if (typeof providerId !== 'string' || !providerId || typeof path !== 'string' || !path || path.length > 512) return undefined;\n const view = target['view'];\n if (!isObject(view)) return undefined;\n let parsedView: ArtifactTarget['view'];\n if (view['kind'] === 'published' && hasOnly(view, ['kind'])) parsedView = { kind: 'published' };\n else if (view['kind'] === 'working' && hasOnly(view, ['kind', 'viewId']) && typeof view['viewId'] === 'string' && view['viewId']) parsedView = { kind: 'working', viewId: view['viewId'] };\n else return undefined;\n return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type as ArtifactType, mediaType, ...(language === undefined ? {} : { language: language as string }),\n target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };\n}\n\n/** The artifact's type name: the matching kind's label first, then the chat's name of its type. */\nexport function typeLabel(artifact: ArtifactDescriptor, kinds?: readonly KindMatch[], labels: Readonly<Record<ArtifactType, string>> = defaultChatLabels.artifactTypes): string {\n const kind = artifactKind(artifact, kinds);\n if (kind?.label) return kind.label;\n return artifact.type === 'code' && artifact.language ? `${labels.code} · ${artifact.language}` : labels[artifact.type];\n}\n\nconst textOf = (result: ToolResultMessage): string => result.content.map(part => part.type === 'text' ? part.text : '').join('');\n\n/**\n * The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.\n * Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without\n * the host's `detect`, only a result of `present` counts: any other tool whose text happens\n * to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.\n */\nexport function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactsConfig['detect']): ArtifactDescriptor | undefined {\n if (result.isError) return undefined;\n if (detect) {\n try { const found = parseArtifact(detect(call, result)); if (found) return found; } catch { /* a faulty host callback shows no card */ }\n }\n if (call.name !== PRESENT_TOOL) return undefined;\n const text = textOf(result);\n if (!text.includes('\"boring.artifact\"')) return undefined;\n try {\n const value: unknown = JSON.parse(text);\n return isObject(value) ? parseArtifact(value['artifact']) : undefined;\n } catch { return undefined; }\n}\n\n/** The title a still-running `present` call announces in its streamed arguments: the file name. */\nexport function pendingTitle(call: ToolCall): string | undefined {\n const path = (call.arguments as Record<string, unknown>)['path'];\n const title = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : undefined;\n return title?.trim() ? title.trim().slice(0, 200) : undefined;\n}\n/** What a still-running `present` call will show: the file path. */\nexport function pendingId(call: ToolCall): string | undefined {\n const path = (call.arguments as Record<string, unknown>)['path'];\n return typeof path === 'string' && path ? path.replace(/^(\\.\\/)+/, '') : undefined;\n}\n\n/**\n * Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known\n * from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.\n */\nexport function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n const calls = new Map<string, ToolCall>(), found: ArtifactDescriptor[] = [];\n for (const entry of view?.entries ?? []) for (const message of entry.model ?? []) {\n if (message.role === 'assistant') for (const part of message.content) { if (part.type === 'toolCall') calls.set(part.id, part); }\n else if (message.role === 'toolResult') {\n const call = calls.get(message.toolCallId);\n const descriptor = call && detectArtifact(call, message, detect);\n if (descriptor) found.push(descriptor);\n }\n }\n return found;\n}\n\n/** What the host's chat answer returns: the kind is all the presents bookkeeping reads. */\nexport interface PresentAnswer { readonly kind: string }\n\nexport interface PresentAcksHost {\n /** The chat's `answer` (the transport's `?op=answer`); undefined when the host has none. */\n readonly answer: (id: string, answer: string) => Promise<PresentAnswer> | undefined;\n /** Open the file in the panel; false when the panel refuses (unsaved changes the person keeps). */\n readonly open: (path: string) => boolean;\n /** This page's identity in the acknowledgement. */\n readonly instanceId: string;\n /** Test seams: the wait between retries and the clock of the retry bound. Default: real timers. */\n readonly wait?: (ms: number) => Promise<void>;\n readonly now?: () => number;\n}\n\n/** The page's state of the presents it must answer, as `AgentWorkspace` reads it on each change. */\nexport interface PresentContext {\n /** The person closed the panel in this turn. */\n readonly closedThisTurn: boolean;\n /** The open file has unsaved edits. */\n readonly unsaved: boolean;\n /** The screen is a phone (the panel is a sheet and is not opened for an agent). */\n readonly narrow: boolean;\n /** `autoOpen`: when false the panel is not opened for an agent; the call waits for a manual open. */\n readonly autoOpen: boolean;\n /** Whether the panel already shows this file. */\n readonly showing: (path: string) => boolean;\n}\n\n/**\n * The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the\n * revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)\n * keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.\n */\nexport function createPresentAcks(host: PresentAcksHost) {\n const decided = new Set<string>();\n /** Calls waiting for their file's mount report: answer ID to path. */\n const mounting = new Map<string, string>();\n const wait = host.wait ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)));\n const now = host.now ?? (() => Date.now());\n /** The last mount report: the file on screen and its revision. */\n let lastShown: { readonly path: string; readonly revision: string } | undefined;\n\n /** Sends one answer; returns the server's outcome kind, or undefined when it was not sent. Retries while the record is not written yet. */\n async function send(id: string, body: Record<string, string>): Promise<string | undefined> {\n const until = now() + 3000;\n for (;;) {\n try {\n const sent = host.answer(id, JSON.stringify(body));\n if (sent === undefined) return undefined;\n const outcome = await sent;\n if (outcome.kind !== 'unknown-question' || now() > until) return outcome.kind;\n } catch { return undefined; /* the call ends at its deadline as unconfirmed */ }\n await wait(20);\n }\n }\n /** Confirms a shown revision. The call keeps its entry while the answer is in flight, so a mount during the flight still confirms it. */\n async function confirm(id: string, path: string, revision: string): Promise<void> {\n const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, path, revision });\n // Only an accepted or conflicting answer ends the call. A denial (another revision), a failed request or an unrecorded call keeps it waiting for a later mount or a live-call check.\n if (kind === 'answered' || kind === 'conflict') mounting.delete(id);\n }\n\n /** Refuses a waiting call; like `confirm`, only an accepted or conflicting answer ends it. */\n async function refuse(id: string, reason: string): Promise<void> {\n const kind = await send(id, { kind: 'refused', reason });\n if (kind === 'answered' || kind === 'conflict') mounting.delete(id);\n }\n\n return {\n /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */\n shown(view: { readonly path: string; readonly revision: string }): void {\n for (const [id, path] of mounting) if (path === view.path) void confirm(id, path, view.revision);\n lastShown = view;\n },\n /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */\n failed(view: { readonly path: string; readonly reason: string }): void {\n if (lastShown?.path === view.path) lastShown = undefined;\n const reason = view.reason.trim().slice(0, 200) || 'the view failed';\n for (const [id, path] of mounting) if (path === view.path) void refuse(id, reason);\n },\n /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */\n hidden(view: { readonly path: string; readonly revision: string }): void {\n if (lastShown?.path === view.path && lastShown.revision === view.revision) lastShown = undefined;\n },\n /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */\n consider(calls: readonly PendingPresent[], context: PresentContext): void {\n const live = new Set(calls.map(call => call.id));\n for (const id of decided) if (!live.has(id)) decided.delete(id);\n for (const id of mounting.keys()) if (!live.has(id)) mounting.delete(id);\n for (const call of calls) {\n if (decided.has(call.id)) continue;\n decided.add(call.id);\n if (context.closedThisTurn) void send(call.id, { kind: 'refused', reason: 'closed by user' });\n else if (context.unsaved) void send(call.id, { kind: 'refused', reason: 'unsaved changes' });\n else if (context.narrow) void send(call.id, { kind: 'refused', reason: 'narrow screen' });\n else {\n mounting.set(call.id, call.path);\n if (lastShown?.path === call.path && context.showing(call.path)) void confirm(call.id, call.path, lastShown.revision);\n else if (context.autoOpen && !host.open(call.path)) {\n mounting.delete(call.id);\n void send(call.id, { kind: 'refused', reason: 'unsaved changes' });\n }\n }\n }\n },\n /** Calls still waiting for a mount (test and debugging view). */\n waiting: (): readonly string[] => [...mounting.keys()],\n };\n}\n\n/** What `createPresentAcks` returns. */\nexport type PresentAcks = ReturnType<typeof createPresentAcks>;\n\n/**\n * The artifact a tool call made, as the chat places it: a finished call that carries a descriptor (its own, or one the host's `detect`\n * finds) is ready; a `present` call still running while its conversation runs (`running`) is presenting. Any other call has none.\n */\nexport function recogniseArtifact(call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ChatArtifactsConfig['detect']): ChatArtifact | undefined {\n if (result) {\n const artifact = detectArtifact(call, result, detect as ArtifactsConfig['detect']);\n if (!artifact) return undefined;\n // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.\n return { state: 'ready', id: artifactKey(artifact), title: artifact.title, path: artifact.target.resource.path, own: detectArtifact(call, result) !== undefined, descriptor: artifact };\n }\n if (running && call.name === PRESENT_TOOL) return { state: 'presenting', id: pendingId(call), title: pendingTitle(call), path: undefined, own: true, descriptor: undefined };\n return undefined;\n}\n",
19
+ "content": "import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ComponentType, ReactNode } from 'react';\nimport { defaultChatLabels } from '../pi-chat/labels';\nimport type { ChatArtifactsConfig } from '../pi-chat/config';\n\n// Compatibility exports for one release; conversation rules live in the kit.\nexport { ARTIFACT_TYPES, PRESENT_TOOL, artifactKey, parseArtifact, detectArtifact, collectArtifacts, pendingPresents, presentPath, pendingTitle, pendingId, createPresentAcks, recogniseArtifact } from '@hachej/boring-ui-kit/chat-view';\nexport type { ArtifactType, ArtifactTarget, ArtifactDescriptor, PendingPresent, PresentAnswer, PresentAcksHost, PresentContext, PresentAcks } from '@hachej/boring-ui-kit/chat-view';\nimport type { ArtifactDescriptor, ArtifactTarget, ArtifactType } from '@hachej/boring-ui-kit/chat-view';\n\n/**\n * What an artifact kind's renderer receives: the artifact and its text at the revision shown, the page's URL fragment without `#`\n * (a link such as `#screen=2` can open a place inside the artifact; the panel follows its changes), and `read`: another file of the same\n * workspace as text (its latest revision; undefined when it is missing or not text), for an artifact that links to a file. `revision` is\n * the shown revision, the one a conditional save is made against; `pinned` is true when the panel shows a pinned historical version,\n * which is read-only.\n */\nexport interface ArtifactKindViewProps {\n readonly artifact: ArtifactDescriptor;\n readonly text: string;\n readonly revision: string;\n readonly pinned: boolean;\n /**\n * The view is loaded and on screen. A kind with `waitsForReady` must call it (once per revision shown): only then does the page\n * acknowledge a `present` call for this file. Other kinds are acknowledged as soon as their file is read, and may ignore it.\n */\n readonly onReady?: (() => void) | undefined;\n /**\n * The view could not be shown (its data failed to load, for example), with a short reason for the agent (at most 200 characters are\n * sent). A `present` call waiting for this file is refused with that reason. A renderer that throws is reported the same way.\n */\n readonly onFailed?: ((reason: string) => void) | undefined;\n readonly fragment?: string | undefined;\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n /**\n * The panel's bar owns the one \"…\" menu: a view publishes its own entries there (a maquette's guided tours) and clears them with `[]`.\n * Absent outside the panel; the view then draws its own menu.\n */\n readonly setMenu?: ((items: readonly ArtifactMenuEntry[]) => void) | undefined;\n /** The panel bar's controls slot: a view puts its own controls there (the SVG preview and source toggle) and clears them with undefined. */\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\n/** One entry a kind's view adds to the panel bar's \"…\" menu (structurally the viewer frame's menu item). */\nexport interface ArtifactMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\n\n/** What a kind's `load` resolves to: a module whose default export is the view. */\nexport type ArtifactKindView = { readonly default: ComponentType<ArtifactKindViewProps> };\n\n/**\n * An app's own kind of artifact (for example `.maquette.json` files): how its card and the panel name it, its icon, and optionally\n * how the panel draws it. Kinds are consulted in order before the built-in types; the first whose `match` is true wins. A kind\n * adds a presentation only: the descriptor, its type and how it is read are unchanged.\n */\nexport interface ArtifactKind {\n /** True for the artifacts of this kind, for example `target => target.resource.path.endsWith('.maquette.json')`. */\n readonly match: (target: ArtifactTarget, artifact: ArtifactDescriptor) => boolean;\n /** The kind's name on the card and in the panel bar (\"Maquette\"). The type's name otherwise. */\n readonly label?: string | undefined;\n /** The card's icon (any component taking `className`, such as a lucide icon). The type's icon otherwise. */\n readonly icon?: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }> | undefined;\n /** Draws the artifact in the panel (`AgentWorkspace`, `ArtifactPanel`) inside the standard viewer bar, with copy and download. */\n readonly renderer?: ((props: ArtifactKindViewProps) => ReactNode) | undefined;\n /**\n * The view's module, loaded the first time an artifact of this kind opens: the panel wraps it once per kind in `React.lazy` with a\n * spinner in the bar, so the view's code (and its libraries) is not in the page's first bundle. Same props and bar as `renderer`; a kind\n * with both draws with `renderer`. Write it as `() => import('./my-view')` so a bundler splits the view into its own chunk.\n */\n readonly load?: (() => Promise<ArtifactKindView>) | undefined;\n /**\n * A default viewer for a whole type or media type (the built-in Markdown, HTML, SVG, CSV, image and PDF kinds): a host's `viewers`\n * entry for that type wins over it, as it did over the built-in viewers.\n */\n readonly fallback?: boolean | undefined;\n /**\n * The renderer loads asynchronously (a dashboard fetching its data, a sandboxed frame): the panel confirms a `present` of this file\n * only after the renderer calls `onReady`, and refuses it when it calls `onFailed` or throws. With no call, the call ends unconfirmed.\n */\n readonly waitsForReady?: boolean | undefined;\n}\n\n/** What every kind shares, whatever its view: the match, its name and icon. The panel's Markdown and HTML editor kinds are kinds too. */\nexport type KindMatch = Pick<ArtifactKind, 'match' | 'label' | 'icon'>;\n\n/** The first kind that matches the artifact; a `match` that throws counts as no match. */\nexport function artifactKind<Kind extends KindMatch>(artifact: ArtifactDescriptor, kinds: readonly Kind[] | undefined): Kind | undefined {\n return kinds?.find(kind => { try { return kind.match(artifact.target, artifact); } catch { return false; } });\n}\n\n/** What the host passes to `PiChat` to turn artifact tool results into cards. */\nexport interface ArtifactsConfig {\n /** The app's own artifact kinds (label, icon, renderer), consulted before the built-in types. */\n readonly kinds?: readonly KindMatch[] | undefined;\n /** The person clicked a card: show this artifact (and revision) in the host's panel. */\n readonly open: (artifact: ArtifactDescriptor) => void;\n /** True for the card whose artifact version is on display. */\n readonly isOpen?: (artifact: ArtifactDescriptor) => boolean;\n /** Recognise a descriptor in a successful tool result that does not carry one itself (for example a document save). */\n readonly detect?: (call: ToolCall, result: ToolResultMessage) => ArtifactDescriptor | undefined;\n /** How the chat finds a call's artifact (`recogniseArtifact`). With `Card`, it is what makes `PiChat` offer artifacts; `artifactCards` sets both. */\n readonly recognise?: ChatArtifactsConfig['recognise'];\n /** The card the chat draws (`PluggedArtifactCard`). */\n readonly Card?: ChatArtifactsConfig['Card'];\n}\n\n/** The artifact's type name: the matching kind's label first, then the chat's name of its type. */\nexport function typeLabel(artifact: ArtifactDescriptor, kinds?: readonly KindMatch[], labels: Readonly<Record<ArtifactType, string>> = defaultChatLabels.artifactTypes): string {\n const kind = artifactKind(artifact, kinds);\n if (kind?.label) return kind.label;\n return artifact.type === 'code' && artifact.language ? `${labels.code} · ${artifact.language}` : labels[artifact.type];\n}\n",
21
20
  "type": "registry:lib",
22
21
  "target": "components/pi-artifacts/artifact.ts"
23
22
  },
@@ -4,16 +4,13 @@
4
4
  "title": "Pi chat approval card",
5
5
  "description": "The Approve / Deny card for host tools wrapped with `requireApproval`. Plug it in with `cards={{ approval: ApprovalCard }}`; without it pi-chat shows a \"waiting for your answer\" notice with no answer UI.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "@earendil-works/pi-ai@~1.2.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
14
- "badge",
15
- "button",
16
- "item"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
17
14
  ],
18
15
  "files": [
19
16
  {
@@ -9,14 +9,12 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
13
- "button",
14
- "input"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
15
13
  ],
16
14
  "files": [
17
15
  {
18
16
  "path": "registry/pi-chat-attachments/camera-sheet.tsx",
19
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CameraIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useChatText } from '../pi-chat/labels';\nimport type { CameraSheetProps } from '../pi-chat/config';\n\nexport type { CameraSheetProps } from '../pi-chat/config';\n\n// The in-page camera behind the composer's \"Take a photo\" (phones). getUserMedia is secure-context only and needs permission, so it is\n// opened only when this sheet mounts (the person's tap on the menu item), through an injectable `getUserMedia` whose default\n// feature-detects `navigator.mediaDevices.getUserMedia`. Where the stream cannot open (refused, unsupported, a Permissions-Policy, an\n// insecure page) the sheet offers the phone's own camera through a file input instead. Same shape as feedback/voice/capture.ts.\n\n/** The longest side of a captured photo, in pixels. */\nexport const CAMERA_MAX_SIDE = 1600;\nconst JPEG_QUALITY = 0.85;\n\ntype GetUserMedia = NonNullable<CameraSheetProps['getUserMedia']>;\n\n/** The browser's camera, or null where the page cannot have one (insecure origin, no media devices). */\nfunction browserGetUserMedia(): GetUserMedia | null {\n if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function') return null;\n const devices = globalThis.navigator.mediaDevices;\n return constraints => devices.getUserMedia(constraints);\n}\n\n/** Full-screen camera above the agent layer (the app shell's point layer + 10): a live preview, a shutter and a close button. */\nexport function CameraSheet({ onCapture, onClose, getUserMedia }: CameraSheetProps) {\n const { labels } = useChatText();\n const video = useRef<HTMLVideoElement>(null);\n const stream = useRef<MediaStream | null>(null);\n const fallback = useRef<HTMLInputElement>(null);\n const [ready, setReady] = useState(false);\n const [failed, setFailed] = useState(false);\n const [encoding, setEncoding] = useState(false);\n // The latest callbacks, so the stream effect runs once per mount.\n const latest = useRef({ onClose, getUserMedia });\n latest.current = { onClose, getUserMedia };\n\n const release = () => { stream.current?.getTracks().forEach(track => track.stop()); stream.current = null; };\n useEffect(() => {\n let live = true;\n const open = latest.current.getUserMedia ?? browserGetUserMedia();\n (async () => {\n if (!open) throw new Error('no camera');\n // The rear camera if there is one; any camera otherwise.\n const media = await open({ video: { facingMode: 'environment' } }).catch(() => open({ video: true }));\n if (!live) { media.getTracks().forEach(track => track.stop()); return; }\n stream.current = media;\n const element = video.current;\n if (!element) return;\n element.srcObject = media;\n await element.play();\n if (live) setReady(true);\n })().catch(() => { if (live) { release(); setFailed(true); } });\n return () => { live = false; release(); };\n }, []);\n // Escape closes the camera only: captured before the drawer or the dropdown behind it see it.\n useEffect(() => {\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); release(); latest.current.onClose(); } };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, []);\n\n const close = () => { release(); onClose(); };\n const shoot = () => {\n const element = video.current;\n if (!element || !ready || encoding || !element.videoWidth || !element.videoHeight) return;\n const scale = Math.min(1, CAMERA_MAX_SIDE / Math.max(element.videoWidth, element.videoHeight));\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.round(element.videoWidth * scale));\n canvas.height = Math.max(1, Math.round(element.videoHeight * scale));\n const context = canvas.getContext('2d');\n if (!context) { setFailed(true); return; }\n context.drawImage(element, 0, 0, canvas.width, canvas.height);\n setEncoding(true);\n canvas.toBlob(blob => {\n setEncoding(false);\n if (!blob) { setFailed(true); return; }\n release();\n onCapture(new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' }));\n }, 'image/jpeg', JPEG_QUALITY);\n };\n\n return createPortal(\n <div role=\"dialog\" aria-modal=\"true\" aria-label={labels.takePhoto} data-boring-overlay=\"\" data-testid=\"camera-sheet\"\n style={{ zIndex: 'calc(var(--app-shell-z-point, 60) + 10)' }}\n className=\"fixed inset-0 flex flex-col bg-background text-foreground\">\n <div className=\"flex min-h-0 flex-1 items-center justify-center overflow-hidden\">\n {failed\n ? <div className=\"flex flex-col items-center gap-3 p-6 text-center\">\n <p role=\"alert\" data-testid=\"camera-unavailable\" className=\"text-sm text-muted-foreground\">{labels.cameraUnavailable}</p>\n {/* Synchronous click: the browser opens the phone camera only from the person's tap. */}\n <Button type=\"button\" data-testid=\"camera-fallback\" className=\"min-h-11\" onClick={() => fallback.current?.click()}>\n <CameraIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.cameraFallback}</Button>\n <Input ref={fallback} type=\"file\" accept=\"image/*\" capture=\"environment\" tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"camera-fallback-input\"\n onChange={event => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (file) onCapture(file); }} />\n </div>\n : <video ref={video} playsInline muted autoPlay data-testid=\"camera-video\" className=\"size-full object-cover\" />}\n </div>\n <div className=\"flex items-center justify-between p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-lg\" data-testid=\"camera-close\" aria-label={labels.cameraClose} title={labels.cameraClose} onClick={close}>\n <XIcon aria-hidden=\"true\" /></Button>\n {!failed && <Button type=\"button\" size=\"icon-lg\" data-testid=\"camera-shoot\" aria-label={labels.cameraShoot} title={labels.cameraShoot}\n disabled={!ready || encoding} onClick={shoot}><CameraIcon aria-hidden=\"true\" /></Button>}\n <span className=\"w-9\" aria-hidden=\"true\" />\n </div>\n </div>, document.body);\n}\n",
17
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CameraIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useChatText } from '../pi-chat/labels';\nimport type { CameraSheetProps } from '../pi-chat/config';\n\nexport type { CameraSheetProps } from '../pi-chat/config';\n\n// The in-page camera behind the composer's \"Take a photo\" (phones). getUserMedia is secure-context only and needs permission, so it is\n// opened only when this sheet mounts (the person's tap on the menu item), through an injectable `getUserMedia` whose default\n// feature-detects `navigator.mediaDevices.getUserMedia`. Where the stream cannot open (refused, unsupported, a Permissions-Policy, an\n// insecure page) the sheet offers the phone's own camera through a file input instead. Same shape as feedback/voice/capture.ts.\n\n/** The longest side of a captured photo, in pixels. */\nexport const CAMERA_MAX_SIDE = 1600;\nconst JPEG_QUALITY = 0.85;\n\ntype GetUserMedia = NonNullable<CameraSheetProps['getUserMedia']>;\n\n/** The browser's camera, or null where the page cannot have one (insecure origin, no media devices). */\nfunction browserGetUserMedia(): GetUserMedia | null {\n if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function') return null;\n const devices = globalThis.navigator.mediaDevices;\n return constraints => devices.getUserMedia(constraints);\n}\n\n/** Full-screen camera above the agent layer (the app shell's point layer + 10): a live preview, a shutter and a close button. */\nexport function CameraSheet({ onCapture, onClose, getUserMedia }: CameraSheetProps) {\n const { labels } = useChatText();\n const video = useRef<HTMLVideoElement>(null);\n const stream = useRef<MediaStream | null>(null);\n const fallback = useRef<HTMLInputElement>(null);\n const [ready, setReady] = useState(false);\n const [failed, setFailed] = useState(false);\n const [encoding, setEncoding] = useState(false);\n // The latest callbacks, so the stream effect runs once per mount.\n const latest = useRef({ onClose, getUserMedia });\n useLayoutEffect(() => { latest.current = { onClose, getUserMedia }; });\n\n const release = () => { stream.current?.getTracks().forEach(track => track.stop()); stream.current = null; };\n useEffect(() => {\n let live = true;\n const open = latest.current.getUserMedia ?? browserGetUserMedia();\n (async () => {\n if (!open) throw new Error('no camera');\n // The rear camera if there is one; any camera otherwise.\n const media = await open({ video: { facingMode: 'environment' } }).catch(() => open({ video: true }));\n if (!live) { media.getTracks().forEach(track => track.stop()); return; }\n stream.current = media;\n const element = video.current;\n if (!element) return;\n element.srcObject = media;\n await element.play();\n if (live) setReady(true);\n })().catch(() => { if (live) { release(); setFailed(true); } });\n return () => { live = false; release(); };\n }, []);\n // Escape closes the camera only: captured before the drawer or the dropdown behind it see it.\n useEffect(() => {\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); release(); latest.current.onClose(); } };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, []);\n\n const close = () => { release(); onClose(); };\n const shoot = () => {\n const element = video.current;\n if (!element || !ready || encoding || !element.videoWidth || !element.videoHeight) return;\n const scale = Math.min(1, CAMERA_MAX_SIDE / Math.max(element.videoWidth, element.videoHeight));\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.round(element.videoWidth * scale));\n canvas.height = Math.max(1, Math.round(element.videoHeight * scale));\n const context = canvas.getContext('2d');\n if (!context) { setFailed(true); return; }\n context.drawImage(element, 0, 0, canvas.width, canvas.height);\n setEncoding(true);\n canvas.toBlob(blob => {\n setEncoding(false);\n if (!blob) { setFailed(true); return; }\n release();\n onCapture(new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' }));\n }, 'image/jpeg', JPEG_QUALITY);\n };\n\n return createPortal(\n <div role=\"dialog\" aria-modal=\"true\" aria-label={labels.takePhoto} data-boring-overlay=\"\" data-testid=\"camera-sheet\"\n style={{ zIndex: 'calc(var(--app-shell-z-point, 60) + 10)' }}\n className=\"fixed inset-0 flex flex-col bg-background text-foreground\">\n <div className=\"flex min-h-0 flex-1 items-center justify-center overflow-hidden\">\n {failed\n ? <div className=\"flex flex-col items-center gap-3 p-6 text-center\">\n <p role=\"alert\" data-testid=\"camera-unavailable\" className=\"text-sm text-muted-foreground\">{labels.cameraUnavailable}</p>\n {/* Synchronous click: the browser opens the phone camera only from the person's tap. */}\n <Button type=\"button\" data-testid=\"camera-fallback\" className=\"min-h-11\" onClick={() => fallback.current?.click()}>\n <CameraIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.cameraFallback}</Button>\n <Input ref={fallback} type=\"file\" accept=\"image/*\" capture=\"environment\" tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"camera-fallback-input\"\n onChange={event => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (file) onCapture(file); }} />\n </div>\n : <video ref={video} playsInline muted autoPlay data-testid=\"camera-video\" className=\"size-full object-cover\" />}\n </div>\n <div className=\"flex items-center justify-between p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-lg\" data-testid=\"camera-close\" aria-label={labels.cameraClose} title={labels.cameraClose} onClick={close}>\n <XIcon aria-hidden=\"true\" /></Button>\n {!failed && <Button type=\"button\" size=\"icon-lg\" data-testid=\"camera-shoot\" aria-label={labels.cameraShoot} title={labels.cameraShoot}\n disabled={!ready || encoding} onClick={shoot}><CameraIcon aria-hidden=\"true\" /></Button>}\n <span className=\"w-9\" aria-hidden=\"true\" />\n </div>\n </div>, document.body);\n}\n",
20
18
  "type": "registry:component",
21
19
  "target": "components/pi-chat-attachments/camera-sheet.tsx"
22
20
  }
@@ -4,20 +4,18 @@
4
4
  "title": "Pi chat Markdown",
5
5
  "description": "Markdown for the chat's assistant replies: safe token-by-token rendering (raw HTML shown as text, no images fetched, http(s) links only), code blocks, tables, task lists and /command buttons. Plug it in with `renderText={markdownText}`; without it pi-chat shows replies as plain text.",
6
6
  "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.27",
7
8
  "react@^19.0.0",
8
9
  "marked@^17.0.6",
9
10
  "entities@^8.1.0"
10
11
  ],
11
12
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
13
- "button",
14
- "checkbox",
15
- "table"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
16
14
  ],
17
15
  "files": [
18
16
  {
19
17
  "path": "registry/pi-chat-markdown/markdown.tsx",
20
- "content": "'use client';\n\nimport { createElement, Fragment, memo, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { Marked } from 'marked';\nimport { decodeHTML } from 'entities';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { pieces } from '../pi-chat/config';\nimport type { CommandMention, CommandMentions } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatLabels } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\n\nexport type { CommandMention, CommandMentions } from '../pi-chat/config';\n\n/*\n * Safe Markdown for model output. Tokens are mapped to React elements one by one: raw HTML is shown as text, images are\n * shown as their alt text (nothing is fetched), and a link is only rendered when it is a plain http(s) URL.\n * Ported from the v4 `@hachej/boring-ui-kit` chat markdown renderer; the styling is Tailwind and fenced code gets a code block card.\n */\nconst markdown = new Marked({ gfm: true, breaks: false, pedantic: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }\nfunction text(value: unknown): string { return typeof value === 'string' ? value : ''; }\nfunction visible(value: unknown): string {\n if (!isRecord(value)) return '';\n if (value['type'] === 'br') return '\\n';\n if (Array.isArray(value['tokens'])) return value['tokens'].map(visible).join('');\n const content = text(value['text']);\n return value['type'] === 'text' && value['escaped'] !== true ? decodeHTML(content) : content;\n}\nfunction containsHtml(value: unknown): boolean {\n if (!isRecord(value)) return false;\n return value['type'] === 'html' || Array.isArray(value['tokens']) && value['tokens'].some(containsHtml);\n}\nfunction literal(value: string): ReactNode { return <span className=\"whitespace-pre-wrap\">{value}</span>; }\n\ninterface Context { readonly renderMention: ((path: string, text: string) => ReactNode) | undefined; readonly mentions: CommandMentions | undefined; readonly onCopy: ((text: string) => Promise<void>) | undefined; readonly labels: ChatLabels }\n\nfunction children(value: unknown, context: Context, before = '', after = '', inline = false): ReactNode {\n if (!Array.isArray(value)) return null;\n if (!context.mentions) return value.map((token: unknown, index) => <Fragment key={index}>{renderToken(token, context)}</Fragment>);\n const shown = value.map(visible), source = before + shown.join('') + after;\n const active = inline && value.some(containsHtml) ? { ...context, mentions: undefined } : context;\n let position = before.length;\n return value.map((token: unknown, index) => {\n const start = position;\n position += shown[index]?.length ?? 0;\n return <Fragment key={index}>{renderToken(token, active, source.slice(0, start), source.slice(position))}</Fragment>;\n });\n}\n\nconst mentionPattern = /(^|[\\s([{\"'“‘])\\/([A-Za-z0-9_][A-Za-z0-9_-]*)(?=$|\\s|[.,;!?)}\\]\"'”’]+(?=$|\\s))/g;\nfunction mentionText(value: string, mentions: CommandMentions | undefined, before: string, after: string, labels: ChatLabels): ReactNode {\n if (!mentions?.commands.length || !value.includes('/')) return value;\n const commands = new Map(mentions.commands.filter(item => /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(item.name)\n && (item.behavior === 'execute' || item.behavior === 'insert')).map(item => [item.name, item]));\n if (!commands.size) return value;\n const source = before + value + after, start = before.length, end = start + value.length;\n const pieces: ReactNode[] = [];\n let cursor = 0;\n for (const match of source.matchAll(mentionPattern)) {\n if (match.index === undefined) continue;\n const boundary = match[1] ?? '', name = match[2];\n if (!name) continue;\n const offset = match.index + boundary.length, limit = offset + name.length + 1;\n const command: CommandMention | undefined = commands.get(name);\n if (!command || offset < start || limit > end || offset - start < cursor) continue;\n const selected = { name: command.name, behavior: command.behavior };\n const at = offset - start;\n if (at > cursor) pieces.push(value.slice(cursor, at));\n pieces.push(<Button key={at} variant=\"secondary\" size=\"sm\" data-testid=\"command-mention\" aria-label={labels.runCommand(selected.name, selected.behavior === 'execute')}\n onClick={() => mentions.onActivate(selected)}>/{selected.name}</Button>);\n cursor = limit - start;\n }\n if (!pieces.length) return value;\n if (cursor < value.length) pieces.push(value.slice(cursor));\n return pieces;\n}\n\nfunction linkTarget(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const decoded = decodeHTML(value);\n // Control characters are what this refuses; the pattern names them on purpose.\n // oxlint-disable-next-line no-control-regex\n if (!/^https?:\\/\\//i.test(decoded) || /[\\u0000- \\u007f-\\u009f\\\\]/.test(decoded)) return undefined;\n const authority = /^https?:\\/\\/([^/?#]*)/i.exec(decoded)?.[1];\n if (!authority || authority.includes('@')) return undefined;\n try {\n const url = new URL(decoded);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;\n return url.href;\n } catch { return undefined; }\n}\n\nfunction cells(value: unknown, header: boolean, context: Context): ReactNode {\n if (!Array.isArray(value)) return null;\n return value.map((cell: unknown, index) => {\n if (!isRecord(cell)) return null;\n const alignment = cell['align'];\n const textAlign = alignment === 'left' || alignment === 'center' || alignment === 'right' ? alignment : undefined;\n return createElement(header ? TableHead : TableCell, { key: index, style: { textAlign }, ...(header ? { scope: 'col' } : {}) },\n children(cell['tokens'], context, '', '', true));\n });\n}\n\nconst HEADINGS: Record<number, string> = {\n 1: 'mt-6 mb-2 text-xl font-semibold tracking-tight', 2: 'mt-5 mb-2 text-base font-semibold tracking-tight', 3: 'mt-4 mb-1.5 text-sm font-semibold',\n 4: 'mt-4 mb-1 text-sm font-semibold', 5: 'mt-3 mb-1 text-sm font-medium', 6: 'mt-3 mb-1 text-sm font-medium text-muted-foreground',\n};\n\n// Marked's extension token includes arbitrary fields. Narrow those fields here before creating React nodes.\nfunction renderToken(value: unknown, context: Context, before = '', after = ''): ReactNode {\n if (!isRecord(value)) return null;\n const raw = text(value['raw']), content = text(value['text']), mentions = context.mentions;\n switch (value['type']) {\n case 'space': case 'def': return null;\n case 'paragraph': return <p className=\"my-3 first:mt-0 last:mb-0\">{children(value['tokens'], context, '', '', true)}</p>;\n case 'heading': {\n const depth = value['depth'];\n return typeof depth === 'number' && Number.isInteger(depth) && depth >= 1 && depth <= 6\n ? createElement(`h${depth}`, { className: `${HEADINGS[depth]} first:mt-0` }, children(value['tokens'], context, '', '', true)) : literal(raw);\n }\n case 'text': return Array.isArray(value['tokens']) ? children(value['tokens'], context, before, after, true)\n : value['escaped'] === true ? literal(content) : context.renderMention\n ? pieces(decodeHTML(content), { mentions: true, skills: [] }).map((piece, index) => <Fragment key={index}>{piece.kind === 'mention' && piece.value !== undefined\n ? context.renderMention?.(piece.value, piece.text) : mentionText(piece.text, mentions, before, after, context.labels)}</Fragment>)\n : mentionText(decodeHTML(content), mentions, before, after, context.labels);\n case 'escape': return content;\n case 'strong': return <strong className=\"font-semibold text-foreground\">{children(value['tokens'], context, before, after, true)}</strong>;\n case 'em': return <em>{children(value['tokens'], context, before, after, true)}</em>;\n case 'del': return <del className=\"text-muted-foreground\">{children(value['tokens'], context, before, after, true)}</del>;\n case 'blockquote': return <blockquote className=\"my-3 px-4 py-2 text-muted-foreground [&_p]:my-1\">{children(value['tokens'], context)}</blockquote>;\n case 'br': return <br />;\n case 'hr': return <hr className=\"my-5 border-border\" />;\n case 'codespan': return <code className=\"bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap\">{content}</code>;\n case 'code': return <CodeBlock code={content} {...(typeof value['lang'] === 'string' && value['lang'] ? { language: value['lang'] } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />;\n case 'html': return value['block'] === true ? <pre className=\"my-3 overflow-x-auto font-mono text-xs whitespace-pre-wrap\">{raw}</pre> : literal(raw);\n case 'image': return literal(decodeHTML(content) || raw);\n case 'link': {\n const href = linkTarget(value['href']), title = typeof value['title'] === 'string' ? decodeHTML(value['title']) : undefined;\n return href ? <a href={href} title={title} target=\"_blank\" rel=\"noopener noreferrer\"\n className=\"font-medium text-foreground underline decoration-muted-foreground/50 underline-offset-4 hover:decoration-foreground\">{children(value['tokens'], context)}</a> : children(value['tokens'], context);\n }\n case 'list': {\n const start = value['start'];\n return value['ordered'] === true\n ? <ol start={typeof start === 'number' && Number.isSafeInteger(start) ? start : undefined} className=\"my-3 list-decimal ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ol>\n : <ul className=\"my-3 list-disc ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ul>;\n }\n case 'list_item': return <li className=\"ps-1 [&+li]:mt-1 [&>p]:my-1 [&>ol]:my-1 [&>ul]:my-1\">{children(value['tokens'], context, '', '', true)}</li>;\n case 'checkbox': return <Checkbox className=\"inline-flex\" checked={value['checked'] === true} disabled aria-label={context.labels.taskStatus} />;\n case 'table': return <Table>\n <TableHeader><TableRow>{cells(value['header'], true, context)}</TableRow></TableHeader>\n <TableBody>{Array.isArray(value['rows']) ? value['rows'].map((row: unknown, index) => <TableRow key={index}>{cells(row, false, context)}</TableRow>) : null}</TableBody></Table>;\n default: return literal(raw);\n }\n}\n\n/** One top-level block; unchanged blocks skip re-rendering while a long answer streams. */\nconst Block = memo(function Block({ token, context }: { readonly token: unknown; readonly context: Context }) {\n return <>{renderToken(token, context)}</>;\n}, (a, b) => isRecord(a.token) && isRecord(b.token) && a.token['raw'] === b.token['raw'] && a.token['type'] === b.token['type'] && a.context.mentions === b.context.mentions && a.context.renderMention === b.context.renderMention && a.context.onCopy === b.context.onCopy && a.context.labels === b.context.labels);\n\nexport const Markdown = memo(function Markdown({ text: source, commandMentions, renderMention, onCopy, streaming = false }: {\n readonly text: string; readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined; readonly commandMentions?: CommandMentions; readonly onCopy?: (text: string) => Promise<void>; readonly streaming?: boolean;\n}) {\n const tokens = useMemo(() => { try { return markdown.lexer(source); } catch { return null; } }, [source]);\n const mentions = source.includes('/') && commandMentions?.commands.length ? commandMentions : undefined;\n const { labels } = useChatText();\n const context = useMemo<Context>(() => ({ mentions, renderMention, onCopy, labels }), [mentions, renderMention, onCopy, labels]);\n const body = useMemo(() => {\n if (!tokens) return literal(source);\n // With mentions enabled, positions are relative to the whole text, so render the token list as one unit.\n if (mentions) return children(tokens, context);\n return tokens.map((token, index) => <Block key={index} token={token} context={context} />);\n }, [tokens, source, mentions, context]);\n return <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 [overflow-wrap:anywhere]\">\n {body}\n </div>;\n});\n\n/** The chat's `renderText` that draws an assistant reply as Markdown: `<PiChat renderText={markdownText} />`. */\nexport const markdownText = ({ text, streaming, renderMention, commandMentions, onCopy }: ChatTextPart): ReactNode =>\n <Markdown text={text} streaming={streaming} renderMention={renderMention} {...(commandMentions ? { commandMentions } : {})} {...(onCopy ? { onCopy } : {})} />;\n",
18
+ "content": "'use client';\n\nimport { createElement, Fragment, memo, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { Marked } from 'marked';\nimport { decodeHTML } from 'entities';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { pieces } from '@hachej/boring-ui-kit/chat-text';\nimport type { CommandMention, CommandMentions } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatLabels } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\n\nexport type { CommandMention, CommandMentions } from '../pi-chat/config';\n\n/*\n * Safe Markdown for model output. Tokens are mapped to React elements one by one: raw HTML is shown as text, images are\n * shown as their alt text (nothing is fetched), and a link is only rendered when it is a plain http(s) URL.\n * Ported from the v4 `@hachej/boring-ui-kit` chat markdown renderer; the styling is Tailwind and fenced code gets a code block card.\n */\nconst markdown = new Marked({ gfm: true, breaks: false, pedantic: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }\nfunction text(value: unknown): string { return typeof value === 'string' ? value : ''; }\nfunction visible(value: unknown): string {\n if (!isRecord(value)) return '';\n if (value['type'] === 'br') return '\\n';\n if (Array.isArray(value['tokens'])) return value['tokens'].map(visible).join('');\n const content = text(value['text']);\n return value['type'] === 'text' && value['escaped'] !== true ? decodeHTML(content) : content;\n}\nfunction containsHtml(value: unknown): boolean {\n if (!isRecord(value)) return false;\n return value['type'] === 'html' || Array.isArray(value['tokens']) && value['tokens'].some(containsHtml);\n}\nfunction literal(value: string): ReactNode { return <span className=\"whitespace-pre-wrap\">{value}</span>; }\n\ninterface Context { readonly renderMention: ((path: string, text: string) => ReactNode) | undefined; readonly mentions: CommandMentions | undefined; readonly onCopy: ((text: string) => Promise<void>) | undefined; readonly labels: ChatLabels }\n\nfunction children(value: unknown, context: Context, before = '', after = '', inline = false): ReactNode {\n if (!Array.isArray(value)) return null;\n if (!context.mentions) return value.map((token: unknown, index) => <Fragment key={index}>{renderToken(token, context)}</Fragment>);\n const shown = value.map(visible), source = before + shown.join('') + after;\n const active = inline && value.some(containsHtml) ? { ...context, mentions: undefined } : context;\n let position = before.length;\n return value.map((token: unknown, index) => {\n const start = position;\n position += shown[index]?.length ?? 0;\n return <Fragment key={index}>{renderToken(token, active, source.slice(0, start), source.slice(position))}</Fragment>;\n });\n}\n\nconst mentionPattern = /(^|[\\s([{\"'“‘])\\/([A-Za-z0-9_][A-Za-z0-9_-]*)(?=$|\\s|[.,;!?)}\\]\"'”’]+(?=$|\\s))/g;\nfunction mentionText(value: string, mentions: CommandMentions | undefined, before: string, after: string, labels: ChatLabels): ReactNode {\n if (!mentions?.commands.length || !value.includes('/')) return value;\n const commands = new Map(mentions.commands.filter(item => /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(item.name)\n && (item.behavior === 'execute' || item.behavior === 'insert')).map(item => [item.name, item]));\n if (!commands.size) return value;\n const source = before + value + after, start = before.length, end = start + value.length;\n const pieces: ReactNode[] = [];\n let cursor = 0;\n for (const match of source.matchAll(mentionPattern)) {\n if (match.index === undefined) continue;\n const boundary = match[1] ?? '', name = match[2];\n if (!name) continue;\n const offset = match.index + boundary.length, limit = offset + name.length + 1;\n const command: CommandMention | undefined = commands.get(name);\n if (!command || offset < start || limit > end || offset - start < cursor) continue;\n const selected = { name: command.name, behavior: command.behavior };\n const at = offset - start;\n if (at > cursor) pieces.push(value.slice(cursor, at));\n pieces.push(<Button key={at} variant=\"secondary\" size=\"sm\" data-testid=\"command-mention\" aria-label={labels.runCommand(selected.name, selected.behavior === 'execute')}\n onClick={() => mentions.onActivate(selected)}>/{selected.name}</Button>);\n cursor = limit - start;\n }\n if (!pieces.length) return value;\n if (cursor < value.length) pieces.push(value.slice(cursor));\n return pieces;\n}\n\nfunction linkTarget(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const decoded = decodeHTML(value);\n // Control characters are what this refuses; the pattern names them on purpose.\n // oxlint-disable-next-line no-control-regex\n if (!/^https?:\\/\\//i.test(decoded) || /[\\u0000- \\u007f-\\u009f\\\\]/.test(decoded)) return undefined;\n const authority = /^https?:\\/\\/([^/?#]*)/i.exec(decoded)?.[1];\n if (!authority || authority.includes('@')) return undefined;\n try {\n const url = new URL(decoded);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;\n return url.href;\n } catch { return undefined; }\n}\n\nfunction cells(value: unknown, header: boolean, context: Context): ReactNode {\n if (!Array.isArray(value)) return null;\n return value.map((cell: unknown, index) => {\n if (!isRecord(cell)) return null;\n const alignment = cell['align'];\n const textAlign = alignment === 'left' || alignment === 'center' || alignment === 'right' ? alignment : undefined;\n return createElement(header ? TableHead : TableCell, { key: index, style: { textAlign }, ...(header ? { scope: 'col' } : {}) },\n children(cell['tokens'], context, '', '', true));\n });\n}\n\nconst HEADINGS: Record<number, string> = {\n 1: 'mt-6 mb-2 text-xl font-semibold tracking-tight', 2: 'mt-5 mb-2 text-base font-semibold tracking-tight', 3: 'mt-4 mb-1.5 text-sm font-semibold',\n 4: 'mt-4 mb-1 text-sm font-semibold', 5: 'mt-3 mb-1 text-sm font-medium', 6: 'mt-3 mb-1 text-sm font-medium text-muted-foreground',\n};\n\n// Marked's extension token includes arbitrary fields. Narrow those fields here before creating React nodes.\nfunction renderToken(value: unknown, context: Context, before = '', after = ''): ReactNode {\n if (!isRecord(value)) return null;\n const raw = text(value['raw']), content = text(value['text']), mentions = context.mentions;\n switch (value['type']) {\n case 'space': case 'def': return null;\n case 'paragraph': return <p className=\"my-3 first:mt-0 last:mb-0\">{children(value['tokens'], context, '', '', true)}</p>;\n case 'heading': {\n const depth = value['depth'];\n return typeof depth === 'number' && Number.isInteger(depth) && depth >= 1 && depth <= 6\n ? createElement(`h${depth}`, { className: `${HEADINGS[depth]} first:mt-0` }, children(value['tokens'], context, '', '', true)) : literal(raw);\n }\n case 'text': return Array.isArray(value['tokens']) ? children(value['tokens'], context, before, after, true)\n : value['escaped'] === true ? literal(content) : context.renderMention\n ? pieces(decodeHTML(content), { mentions: true, skills: [] }).map((piece, index) => <Fragment key={index}>{piece.kind === 'mention' && piece.value !== undefined\n ? context.renderMention?.(piece.value, piece.text) : mentionText(piece.text, mentions, before, after, context.labels)}</Fragment>)\n : mentionText(decodeHTML(content), mentions, before, after, context.labels);\n case 'escape': return content;\n case 'strong': return <strong className=\"font-semibold text-foreground\">{children(value['tokens'], context, before, after, true)}</strong>;\n case 'em': return <em>{children(value['tokens'], context, before, after, true)}</em>;\n case 'del': return <del className=\"text-muted-foreground\">{children(value['tokens'], context, before, after, true)}</del>;\n case 'blockquote': return <blockquote className=\"my-3 px-4 py-2 text-muted-foreground [&_p]:my-1\">{children(value['tokens'], context)}</blockquote>;\n case 'br': return <br />;\n case 'hr': return <hr className=\"my-5 border-border\" />;\n case 'codespan': return <code className=\"bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap\">{content}</code>;\n case 'code': return <CodeBlock code={content} {...(typeof value['lang'] === 'string' && value['lang'] ? { language: value['lang'] } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />;\n case 'html': return value['block'] === true ? <pre className=\"my-3 overflow-x-auto font-mono text-xs whitespace-pre-wrap\">{raw}</pre> : literal(raw);\n case 'image': return literal(decodeHTML(content) || raw);\n case 'link': {\n const href = linkTarget(value['href']), title = typeof value['title'] === 'string' ? decodeHTML(value['title']) : undefined;\n return href ? <a href={href} title={title} target=\"_blank\" rel=\"noopener noreferrer\"\n className=\"font-medium text-foreground underline decoration-muted-foreground/50 underline-offset-4 hover:decoration-foreground\">{children(value['tokens'], context)}</a> : children(value['tokens'], context);\n }\n case 'list': {\n const start = value['start'];\n return value['ordered'] === true\n ? <ol start={typeof start === 'number' && Number.isSafeInteger(start) ? start : undefined} className=\"my-3 list-decimal ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ol>\n : <ul className=\"my-3 list-disc ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ul>;\n }\n case 'list_item': return <li className=\"ps-1 [&+li]:mt-1 [&>p]:my-1 [&>ol]:my-1 [&>ul]:my-1\">{children(value['tokens'], context, '', '', true)}</li>;\n case 'checkbox': return <Checkbox className=\"inline-flex\" checked={value['checked'] === true} disabled aria-label={context.labels.taskStatus} />;\n case 'table': return <Table>\n <TableHeader><TableRow>{cells(value['header'], true, context)}</TableRow></TableHeader>\n <TableBody>{Array.isArray(value['rows']) ? value['rows'].map((row: unknown, index) => <TableRow key={index}>{cells(row, false, context)}</TableRow>) : null}</TableBody></Table>;\n default: return literal(raw);\n }\n}\n\n/** One top-level block; unchanged blocks skip re-rendering while a long answer streams. */\nconst Block = memo(function Block({ token, context }: { readonly token: unknown; readonly context: Context }) {\n return <>{renderToken(token, context)}</>;\n}, (a, b) => isRecord(a.token) && isRecord(b.token) && a.token['raw'] === b.token['raw'] && a.token['type'] === b.token['type'] && a.context.mentions === b.context.mentions && a.context.renderMention === b.context.renderMention && a.context.onCopy === b.context.onCopy && a.context.labels === b.context.labels);\n\nexport const Markdown = memo(function Markdown({ text: source, commandMentions, renderMention, onCopy, streaming = false }: {\n readonly text: string; readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined; readonly commandMentions?: CommandMentions; readonly onCopy?: (text: string) => Promise<void>; readonly streaming?: boolean;\n}) {\n const tokens = useMemo(() => { try { return markdown.lexer(source); } catch { return null; } }, [source]);\n const mentions = source.includes('/') && commandMentions?.commands.length ? commandMentions : undefined;\n const { labels } = useChatText();\n const context = useMemo<Context>(() => ({ mentions, renderMention, onCopy, labels }), [mentions, renderMention, onCopy, labels]);\n const body = useMemo(() => {\n if (!tokens) return literal(source);\n // With mentions enabled, positions are relative to the whole text, so render the token list as one unit.\n if (mentions) return children(tokens, context);\n return tokens.map((token, index) => <Block key={index} token={token} context={context} />);\n }, [tokens, source, mentions, context]);\n return <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 [overflow-wrap:anywhere]\">\n {body}\n </div>;\n});\n\n/** The chat's `renderText` that draws an assistant reply as Markdown: `<PiChat renderText={markdownText} />`. */\nexport const markdownText = ({ text, streaming, renderMention, commandMentions, onCopy }: ChatTextPart): ReactNode =>\n <Markdown text={text} streaming={streaming} renderMention={renderMention} {...(commandMentions ? { commandMentions } : {})} {...(onCopy ? { onCopy } : {})} />;\n",
21
19
  "type": "registry:component",
22
20
  "target": "components/pi-chat-markdown/markdown.tsx"
23
21
  }
@@ -8,13 +8,12 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
12
- "item"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
13
12
  ],
14
13
  "files": [
15
14
  {
16
15
  "path": "registry/pi-chat-mentions/mention-menu.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { FileTextIcon, FolderIcon } from 'lucide-react';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from '../pi-chat/picker-keyboard';\nimport { ComposerSuggestions } from '../pi-chat/suggestions';\nimport type { MentionPickerProps, MentionResult, MentionsConfig } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\nfunction highlight(text: string, query: string) {\n const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n if (at < 0) return <>{text}</>;\n return <>{text.slice(0, at)}<mark className=\"bg-transparent text-foreground underline underline-offset-2\">{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The `@` file picker, ported from v2's `MentionPicker`: debounced search, one highlighted row, name over path. */\nexport function MentionPicker({ query, search, generation, listLabel, renderOption, onSelect, onDismiss }: MentionPickerProps) {\n const { labels } = useChatText();\n // Results are tagged with the search function that produced them: a binding switch hides the previous binding's rows at once.\n const [outcome, setOutcome] = useState<{ readonly search: MentionsConfig['search']; readonly results: readonly MentionResult[]; readonly failure: string | null }>();\n const shown = outcome?.search === search ? outcome : undefined;\n const results = shown?.results ?? [];\n const failure = shown?.failure ?? null;\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n const searcher = useRef(search); searcher.current = search;\n useEffect(() => {\n setActive(0);\n const abort = new AbortController();\n const timer = setTimeout(() => {\n searcher.current(query, abort.signal).then(found => { if (!abort.signal.aborted) setOutcome({ search, results: found.slice(0, 8), failure: null }); },\n cause => { if (!abort.signal.aborted) setOutcome({ search, results: [], failure: cause instanceof Error ? cause.message : labels.searchFailed }); });\n }, 120);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [query, search, generation]);\n usePickerKeyboard({ count: results.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = results[index]; if (item) onSelect(item.path); }, onDismiss });\n\n if (results.length === 0 && !failure) return null;\n return <ComposerSuggestions testId=\"mention-menu\" onDismiss={onDismiss}>\n {failure ? <div role=\"alert\" data-testid=\"mention-error\" className=\"text-xs text-destructive\">{failure}</div>\n : <ul ref={list} role=\"listbox\" aria-label={listLabel ?? labels.workspaceFiles} className=\"m-0 flex max-h-[min(14rem,40dvh)] list-none flex-col overflow-y-auto p-0\">\n {results.map((item, index) => {\n const name = item.label ?? item.path.split('/').pop() ?? item.path;\n const Icon = item.kind === 'directory' ? FolderIcon : FileTextIcon;\n return <Item key={item.path} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} role=\"option\" aria-selected={index === active} data-testid=\"mention-item\" data-path={item.path} title={item.label ?? item.path}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item.path); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n {renderOption ? renderOption(item, query) : <><ItemMedia variant=\"icon\"><Icon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0\"><ItemTitle>{highlight(name, query)}</ItemTitle>\n <ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>}\n </Item>;\n })}\n </ul>}\n </ComposerSuggestions>;\n}\n",
16
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { FileTextIcon, FolderIcon } from 'lucide-react';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from '../pi-chat/picker-keyboard';\nimport { ComposerSuggestions } from '../pi-chat/suggestions';\nimport type { MentionPickerProps, MentionResult, MentionsConfig } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\nfunction highlight(text: string, query: string) {\n const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n if (at < 0) return <>{text}</>;\n return <>{text.slice(0, at)}<mark className=\"bg-transparent text-foreground underline underline-offset-2\">{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The `@` file picker, ported from v2's `MentionPicker`: debounced search, one highlighted row, name over path. */\nexport function MentionPicker({ query, search, generation, listLabel, renderOption, onSelect, onDismiss }: MentionPickerProps) {\n const { labels } = useChatText();\n // Results are tagged with the search function that produced them: a binding switch hides the previous binding's rows at once.\n const [outcome, setOutcome] = useState<{ readonly search: MentionsConfig['search']; readonly results: readonly MentionResult[]; readonly failure: string | null }>();\n const shown = outcome?.search === search ? outcome : undefined;\n const results = shown?.results ?? [];\n const failure = shown?.failure ?? null;\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new query, search or generation starts at the first row, together with the search it starts\n setActive(0);\n const abort = new AbortController();\n const timer = setTimeout(() => {\n search(query, abort.signal).then(found => { if (!abort.signal.aborted) setOutcome({ search, results: found.slice(0, 8), failure: null }); },\n cause => { if (!abort.signal.aborted) setOutcome({ search, results: [], failure: cause instanceof Error ? cause.message : labels.searchFailed }); });\n }, 120);\n return () => { clearTimeout(timer); abort.abort(); };\n // eslint-disable-next-line react-hooks/exhaustive-deps -- the failure text is read when a search fails; a new label must not search again\n }, [query, search, generation]);\n usePickerKeyboard({ count: results.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = results[index]; if (item) onSelect(item.path); }, onDismiss });\n\n if (results.length === 0 && !failure) return null;\n return <ComposerSuggestions testId=\"mention-menu\" onDismiss={onDismiss}>\n {failure ? <div role=\"alert\" data-testid=\"mention-error\" className=\"text-xs text-destructive\">{failure}</div>\n : <ul ref={list} role=\"listbox\" aria-label={listLabel ?? labels.workspaceFiles} className=\"m-0 flex max-h-[min(14rem,40dvh)] list-none flex-col overflow-y-auto p-0\">\n {results.map((item, index) => {\n const name = item.label ?? item.path.split('/').pop() ?? item.path;\n const Icon = item.kind === 'directory' ? FolderIcon : FileTextIcon;\n return <Item key={item.path} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} role=\"option\" aria-selected={index === active} data-testid=\"mention-item\" data-path={item.path} title={item.label ?? item.path}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item.path); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n {renderOption ? renderOption(item, query) : <><ItemMedia variant=\"icon\"><Icon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0\"><ItemTitle>{highlight(name, query)}</ItemTitle>\n <ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>}\n </Item>;\n })}\n </ul>}\n </ComposerSuggestions>;\n}\n",
18
17
  "type": "registry:component",
19
18
  "target": "components/pi-chat-mentions/mention-menu.tsx"
20
19
  }
@@ -8,14 +8,12 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
12
- "button",
13
- "dropdown-menu"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
14
12
  ],
15
13
  "files": [
16
14
  {
17
15
  "path": "registry/pi-chat-pickers/pickers.tsx",
18
- "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 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 '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",
19
17
  "type": "registry:component",
20
18
  "target": "components/pi-chat-pickers/pickers.tsx"
21
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.25/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-markdown.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-slash.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-mentions.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-pickers.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-voice.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-attachments.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-approval.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-subagent.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-artifacts.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-markdown.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-slash.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-mentions.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-pickers.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-voice.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-attachments.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-approval.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-subagent.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-artifacts.json"
21
21
  ],
22
22
  "files": [
23
23
  {
@@ -9,13 +9,7 @@
9
9
  "@earendil-works/pi-ai@~1.2.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
13
- "alert",
14
- "button",
15
- "card",
16
- "input",
17
- "item",
18
- "spinner"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
19
13
  ],
20
14
  "files": [
21
15
  {
@@ -4,20 +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.27",
7
8
  "react@^19.0.0",
8
9
  "lucide-react@^1.51.0"
9
10
  ],
10
11
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
12
- "badge",
13
- "button",
14
- "input-group",
15
- "item"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
16
13
  ],
17
14
  "files": [
18
15
  {
19
16
  "path": "registry/pi-chat-slash/slash-menu.tsx",
20
- "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 '../pi-chat/config';\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 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 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 '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",
21
18
  "type": "registry:component",
22
19
  "target": "components/pi-chat-slash/slash-menu.tsx"
23
20
  }
@@ -9,23 +9,18 @@
9
9
  "@earendil-works/pi-ai@~1.2.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
13
- "alert",
14
- "badge",
15
- "button",
16
- "item",
17
- "spinner"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
18
13
  ],
19
14
  "files": [
20
15
  {
21
16
  "path": "registry/pi-chat-subagent/run-card.tsx",
22
- "content": "'use client';\n\nimport { useEffect, 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); 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 useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresh(abort.signal);\n const timer = setInterval(() => { void refresh(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]); // eslint-disable-line react-hooks/exhaustive-deps\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 '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",
23
18
  "type": "registry:component",
24
19
  "target": "components/pi-chat-subagent/run-card.tsx"
25
20
  },
26
21
  {
27
22
  "path": "registry/pi-chat-subagent/subagent-card.tsx",
28
- "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 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 '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",
29
24
  "type": "registry:component",
30
25
  "target": "components/pi-chat-subagent/subagent-card.tsx"
31
26
  }