@hachej/boring-ui-kit 0.4.12 → 0.4.13

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 (48) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +9 -9
  4. package/r/board-viewer.json +12 -6
  5. package/r/button.json +1 -1
  6. package/r/diagram-viewer.json +8 -2
  7. package/r/dock.json +1 -1
  8. package/r/documents.json +14 -12
  9. package/r/feedback-preview.json +1 -1
  10. package/r/feedback.json +6 -6
  11. package/r/file-tree.json +5 -5
  12. package/r/highlights.json +3 -3
  13. package/r/html-viewer.json +3 -3
  14. package/r/looks-viewer.json +9 -3
  15. package/r/maquette-viewer.json +9 -3
  16. package/r/markdown-editor.json +3 -3
  17. package/r/mockup-viewer.json +14 -2
  18. package/r/pi-ambient.json +10 -10
  19. package/r/pi-app.json +31 -20
  20. package/r/pi-artifacts.json +3 -3
  21. package/r/pi-chat-approval.json +1 -1
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +2 -2
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +1 -1
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +2 -2
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +4 -4
  33. package/r/placed-agent.json +4 -4
  34. package/r/provider-setup.json +2 -2
  35. package/r/record-viewer.json +8 -2
  36. package/r/registry.json +396 -203
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +6 -6
  39. package/r/task-list-viewer.json +2 -2
  40. package/r/version-history.json +3 -3
  41. package/r/viewer-code.json +36 -0
  42. package/r/viewer-frame.json +447 -0
  43. package/r/viewer-html.json +32 -0
  44. package/r/viewer-image.json +36 -0
  45. package/r/viewer-markdown.json +47 -0
  46. package/r/viewer-pdf.json +45 -0
  47. package/r/viewers.json +8 -491
  48. package/r/windows.json +1 -1
@@ -10,20 +10,20 @@
10
10
  "@earendil-works/pi-durable@~1.2.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
14
14
  "button",
15
15
  "item"
16
16
  ],
17
17
  "files": [
18
18
  {
19
19
  "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\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\"). */\n readonly label: string;\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 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/** The first kind that matches the artifact; a `match` that throws counts as no match. */\nexport function artifactKind(artifact: ArtifactDescriptor, kinds: readonly ArtifactKind[] | undefined): ArtifactKind | 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 ArtifactKind[] | 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 ArtifactKind[], labels: Readonly<Record<ArtifactType, string>> = defaultChatLabels.artifactTypes): string {\n const kind = artifactKind(artifact, kinds);\n if (kind) 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",
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",
21
21
  "type": "registry:lib",
22
22
  "target": "components/pi-artifacts/artifact.ts"
23
23
  },
24
24
  {
25
25
  "path": "registry/pi-artifacts/artifact-card.tsx",
26
- "content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { CheckIcon, ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Shimmer } from '../pi-chat/shimmer';\nimport { artifactKey, artifactKind, recogniseArtifact, typeLabel } from './artifact';\nimport type { ArtifactDescriptor, ArtifactKind, ArtifactType } from './artifact';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatArtifactCardProps } from '../pi-chat/config';\n\nconst ICONS: Record<ArtifactType, ComponentType<{ readonly 'aria-hidden'?: boolean | 'true' }>> = {\n markdown: FileTextIcon, html: GlobeIcon, svg: ImageIcon, code: FileCodeIcon, canvas: PenToolIcon,\n};\n\n/**\n * An artifact in the transcript, as in Claude's chat apps: a compact card with a type icon, the title, the type and the\n * version. Clicking it asks the host to open the artifact. While the `present` call that makes it is still running it is a quiet\n * \"opening\" card with a shimmer. Rendered outside the activity block, like the question card.\n */\nexport function ArtifactCard({ artifact, pending, title, open = false, onOpen, kinds }: {\n readonly artifact?: ArtifactDescriptor | undefined;\n readonly pending?: 'presenting';\n readonly title?: string | undefined;\n readonly open?: boolean;\n readonly onOpen?: ((artifact: ArtifactDescriptor) => void) | undefined;\n /** The app's artifact kinds (`ArtifactsConfig.kinds`): their label and icon win over the type's. */\n readonly kinds?: readonly ArtifactKind[] | undefined;\n}) {\n const { labels } = useChatText();\n if (!artifact) {\n return <Item variant=\"outline\" size=\"sm\" data-testid=\"artifact-card\" data-state={pending ?? 'presenting'} role=\"status\" aria-busy=\"true\" className=\"my-2 text-start\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><ItemMedia variant=\"icon\"><FileTextIcon aria-hidden=\"true\" /></ItemMedia></Item></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle data-testid=\"artifact-title\"><Shimmer>{title ?? labels.artifact}</Shimmer></ItemTitle>\n <ItemDescription><Shimmer>{labels.opening}</Shimmer></ItemDescription>\n </ItemContent>\n </Item>;\n }\n const kind = artifactKind(artifact, kinds);\n const Icon = kind?.icon ?? ICONS[artifact.type];\n return <Button variant={open ? 'secondary' : 'outline'} disabled={!onOpen} onClick={() => onOpen?.(artifact)}\n data-testid=\"artifact-card\" data-state=\"ready\" data-artifact-id={artifactKey(artifact)} data-artifact-revision={artifact.revision} data-artifact-version={artifact.ordinal} data-artifact-type={artifact.type} data-artifact-kind={kind?.label}\n data-open={open ? 'true' : undefined} aria-pressed={onOpen ? open : undefined} aria-label={labels.openArtifact(artifact.title, artifact.ordinal)}\n className=\"my-2 h-auto min-h-12! w-full max-w-md justify-start gap-2.5 text-left\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><Icon aria-hidden=\"true\" /></Item></ItemMedia>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"artifact-title\" className=\"truncate\">{artifact.title}</span>\n <span className=\"truncate text-xs text-muted-foreground\"><span data-testid=\"artifact-type\">{typeLabel(artifact, kinds, labels.artifactTypes)}</span>{artifact.ordinal !== undefined && <> · <span data-testid=\"artifact-version\">{labels.version(artifact.ordinal)}</span></>}</span>\n </span>\n {open ? <CheckIcon data-icon=\"inline-end\" aria-hidden=\"true\" /> : <ChevronRightIcon data-icon=\"inline-end\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/** `ArtifactCard` as the chat's `artifacts.Card`: the chat hands back the descriptor `recogniseArtifact` found. */\nexport function PluggedArtifactCard({ artifact, open, onOpen, kinds }: ChatArtifactCardProps) {\n const descriptor = artifact.state === 'ready' ? artifact.descriptor as ArtifactDescriptor : undefined;\n return <ArtifactCard artifact={descriptor} title={artifact.title} {...(artifact.state === 'ready' ? {} : { pending: artifact.state })}\n open={open} onOpen={onOpen ? () => onOpen() : undefined} kinds={kinds as readonly ArtifactKind[] | undefined} />;\n}\n\n/** What makes `PiChat` offer artifacts: `<PiChat artifacts={{ open, isOpen, kinds, ...artifactCards }} />` (`withChatPlugins` adds it). */\nexport const artifactCards = { recognise: recogniseArtifact, Card: PluggedArtifactCard } as const;\n",
26
+ "content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { CheckIcon, ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Shimmer } from '../pi-chat/shimmer';\nimport { artifactKey, artifactKind, recogniseArtifact, typeLabel } from './artifact';\nimport type { ArtifactDescriptor, ArtifactType, KindMatch } from './artifact';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatArtifactCardProps } from '../pi-chat/config';\n\nconst ICONS: Record<ArtifactType, ComponentType<{ readonly 'aria-hidden'?: boolean | 'true' }>> = {\n markdown: FileTextIcon, html: GlobeIcon, svg: ImageIcon, code: FileCodeIcon, canvas: PenToolIcon,\n};\n\n/**\n * An artifact in the transcript, as in Claude's chat apps: a compact card with a type icon, the title, the type and the\n * version. Clicking it asks the host to open the artifact. While the `present` call that makes it is still running it is a quiet\n * \"opening\" card with a shimmer. Rendered outside the activity block, like the question card.\n */\nexport function ArtifactCard({ artifact, pending, title, open = false, onOpen, kinds }: {\n readonly artifact?: ArtifactDescriptor | undefined;\n readonly pending?: 'presenting';\n readonly title?: string | undefined;\n readonly open?: boolean;\n readonly onOpen?: ((artifact: ArtifactDescriptor) => void) | undefined;\n /** The app's artifact kinds (`ArtifactsConfig.kinds`): their label and icon win over the type's. */\n readonly kinds?: readonly KindMatch[] | undefined;\n}) {\n const { labels } = useChatText();\n if (!artifact) {\n return <Item variant=\"outline\" size=\"sm\" data-testid=\"artifact-card\" data-state={pending ?? 'presenting'} role=\"status\" aria-busy=\"true\" className=\"my-2 text-start\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><ItemMedia variant=\"icon\"><FileTextIcon aria-hidden=\"true\" /></ItemMedia></Item></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle data-testid=\"artifact-title\"><Shimmer>{title ?? labels.artifact}</Shimmer></ItemTitle>\n <ItemDescription><Shimmer>{labels.opening}</Shimmer></ItemDescription>\n </ItemContent>\n </Item>;\n }\n const kind = artifactKind(artifact, kinds);\n const Icon = kind?.icon ?? ICONS[artifact.type];\n return <Button variant={open ? 'secondary' : 'outline'} disabled={!onOpen} onClick={() => onOpen?.(artifact)}\n data-testid=\"artifact-card\" data-state=\"ready\" data-artifact-id={artifactKey(artifact)} data-artifact-revision={artifact.revision} data-artifact-version={artifact.ordinal} data-artifact-type={artifact.type} data-artifact-kind={kind?.label}\n data-open={open ? 'true' : undefined} aria-pressed={onOpen ? open : undefined} aria-label={labels.openArtifact(artifact.title, artifact.ordinal)}\n className=\"my-2 h-auto min-h-12! w-full max-w-md justify-start gap-2.5 text-left\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><Icon aria-hidden=\"true\" /></Item></ItemMedia>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"artifact-title\" className=\"truncate\">{artifact.title}</span>\n <span className=\"truncate text-xs text-muted-foreground\"><span data-testid=\"artifact-type\">{typeLabel(artifact, kinds, labels.artifactTypes)}</span>{artifact.ordinal !== undefined && <> · <span data-testid=\"artifact-version\">{labels.version(artifact.ordinal)}</span></>}</span>\n </span>\n {open ? <CheckIcon data-icon=\"inline-end\" aria-hidden=\"true\" /> : <ChevronRightIcon data-icon=\"inline-end\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/** `ArtifactCard` as the chat's `artifacts.Card`: the chat hands back the descriptor `recogniseArtifact` found. */\nexport function PluggedArtifactCard({ artifact, open, onOpen, kinds }: ChatArtifactCardProps) {\n const descriptor = artifact.state === 'ready' ? artifact.descriptor as ArtifactDescriptor : undefined;\n return <ArtifactCard artifact={descriptor} title={artifact.title} {...(artifact.state === 'ready' ? {} : { pending: artifact.state })}\n open={open} onOpen={onOpen ? () => onOpen() : undefined} kinds={kinds as readonly KindMatch[] | undefined} />;\n}\n\n/** What makes `PiChat` offer artifacts: `<PiChat artifacts={{ open, isOpen, kinds, ...artifactCards }} />` (`withChatPlugins` adds it). */\nexport const artifactCards = { recognise: recogniseArtifact, Card: PluggedArtifactCard } as const;\n",
27
27
  "type": "registry:component",
28
28
  "target": "components/pi-artifacts/artifact-card.tsx"
29
29
  }
@@ -9,7 +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.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "badge",
14
14
  "button",
15
15
  "item"
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "button",
14
14
  "input"
15
15
  ],
@@ -9,7 +9,7 @@
9
9
  "entities@^8.1.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "button",
14
14
  "checkbox",
15
15
  "table"
@@ -17,7 +17,7 @@
17
17
  "files": [
18
18
  {
19
19
  "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 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",
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",
21
21
  "type": "registry:component",
22
22
  "target": "components/pi-chat-markdown/markdown.tsx"
23
23
  }
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
12
12
  "item"
13
13
  ],
14
14
  "files": [
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
12
12
  "button",
13
13
  "dropdown-menu"
14
14
  ],
@@ -7,17 +7,17 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-markdown.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-slash.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-mentions.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-pickers.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-voice.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-attachments.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-approval.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-subagent.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-artifacts.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-markdown.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-slash.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-mentions.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-pickers.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-voice.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-attachments.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-approval.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-subagent.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-artifacts.json"
21
21
  ],
22
22
  "files": [
23
23
  {
@@ -9,7 +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.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "alert",
14
14
  "button",
15
15
  "card",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
12
12
  "badge",
13
13
  "button",
14
14
  "input-group",
@@ -9,7 +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.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "alert",
14
14
  "badge",
15
15
  "button",
@@ -4,12 +4,12 @@
4
4
  "title": "Pi chat voice messages",
5
5
  "description": "Hold-to-record voice messages: the recorder hook over the kit's voice capture, the composer's record button and the pending voice bubble, plus useHoldToRecord. Plug it in with `voiceMessage={{ ...config, ...voiceMessagePlugin }}`; without it pi-chat has no voice button.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.12",
7
+ "@hachej/boring-ui-kit@^0.4.13",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
13
  "bubble",
14
14
  "button",
15
15
  "message",
package/r/pi-chat.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The question, approval, sub-agent and artifact cards are separate items (pi-chat-question, pi-chat-approval, pi-chat-subagent, pi-artifacts); without them a question or an approval shows a \"waiting for your answer\" notice, a sub-agent call is an ordinary tool step and no artifact is offered. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.12",
8
- "@hachej/boring-files@^0.4.12",
9
- "@hachej/boring-feedback@^0.4.12",
7
+ "@hachej/boring-ui-kit@^0.4.13",
8
+ "@hachej/boring-files@^0.4.13",
9
+ "@hachej/boring-feedback@^0.4.13",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "@base-ui/react@1.9.0",
@@ -19,8 +19,8 @@
19
19
  "zod@^4.6.5"
20
20
  ],
21
21
  "registryDependencies": [
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/button.json",
24
24
  "dialog",
25
25
  "tooltip",
26
26
  "trace",
@@ -8,10 +8,10 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/dock.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/dock.json",
15
15
  "badge",
16
16
  "card"
17
17
  ],
@@ -9,10 +9,10 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-app.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-ambient.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-app.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-ambient.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
16
16
  "button"
17
17
  ],
18
18
  "files": [
@@ -9,8 +9,8 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
14
14
  "button",
15
15
  "input",
16
16
  "label",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
12
12
  "table",
13
13
  "badge",
14
14
  "item",
@@ -31,9 +31,15 @@
31
31
  },
32
32
  {
33
33
  "path": "registry/record-viewer/record-viewer.tsx",
34
- "content": "'use client';\n\nimport { useMemo } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { Rows3Icon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Empty, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Item, ItemActions, ItemContent, ItemGroup } from '@/components/ui/item';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport { formatRecordValue, isRecordsPath, parseRecords, recordValue } from './records';\nimport type { RecordAction, RecordColumn, RecordRow, RecordStatus, RecordTone } from './records';\nimport { recordsLabelsFor } from './records-labels';\nimport type { RecordsLabels, RecordsLocale } from './records-labels';\n\nexport { formatRecordValue, isRecordsPath, parseRecords } from './records';\nexport type { RecordAction, RecordColumn, RecordColumnType, RecordRow, RecordStatus, RecordTone, RecordValue, RecordsFile, RecordsParse } from './records';\nexport { defaultRecordsLabels, frenchRecordsLabels, recordsLabelsFor } from './records-labels';\nexport type { RecordsLabels, RecordsLocale } from './records-labels';\n\nconst variants = { ok: 'default', warn: 'secondary', error: 'destructive', info: 'outline', neutral: 'secondary' } as const satisfies Record<RecordTone, string>;\nfunction Status({ status, label }: { readonly status: RecordStatus; readonly label?: string | undefined }) {\n return <Badge data-record-status={status.tone} variant={variants[status.tone]} role={label ? 'img' : undefined} aria-label={label ? `${label}: ${status.label}` : undefined}>{status.label}</Badge>;\n}\nfunction NoRecords({ label }: { readonly label: string }) {\n return <Empty><EmptyHeader><EmptyTitle>{label}</EmptyTitle></EmptyHeader></Empty>;\n}\ninterface Words {\n readonly locale?: RecordsLocale | undefined;\n readonly labels?: Partial<RecordsLabels> | undefined;\n}\nexport interface RecordTableProps extends Words {\n readonly columns: readonly RecordColumn[];\n readonly rows: readonly RecordRow[];\n readonly groupBy?: string | undefined;\n readonly onAction?: ((action: RecordAction, row: RecordRow) => void) | undefined;\n}\n\n/** Groups retain their first-seen order; the key is the typed raw value, never its rounded display label. */\nfunction groupsOf(rows: readonly RecordRow[], columns: readonly RecordColumn[], groupBy: string | undefined, locale: RecordsLocale | undefined, labels: RecordsLabels) {\n const column = columns.find(column => column.key === groupBy);\n if (!column) return [{ key: '', label: undefined, rows }];\n const groups = new Map<string, { key: string; label: string; rows: RecordRow[] }>();\n for (const row of rows) {\n const value = recordValue(row, column.key), key = JSON.stringify(value !== null && typeof value === 'object' ? [value.tone, value.label] : value);\n let group = groups.get(key);\n if (!group) { group = { key, label: `${column.label}: ${formatRecordValue(value, column.type, locale, labels.missing)}`, rows: [] }; groups.set(key, group); }\n group.rows.push(row);\n }\n return [...groups.values()];\n}\n\n/** A typed table of app data. Actions call the host only; their file metadata grants no permission. */\nexport function RecordTable({ columns, rows, groupBy, onAction, locale, labels: given }: RecordTableProps) {\n const labels = recordsLabelsFor(locale, given);\n const groups = groupsOf(rows, columns, groupBy, locale, labels);\n const fields = columns.filter(column => column.key !== groupBy);\n const actions = rows.some(row => row.actions?.length);\n if (!rows.length) return <NoRecords label={labels.empty} />;\n return <div data-boring=\"record-table\" className=\"min-w-0 overflow-auto\"><Table aria-label={labels.kind}>\n <TableHeader><TableRow>{fields.map(column => <TableHead key={column.key} scope=\"col\" className={column.type === 'number' ? 'text-right' : undefined}>{column.label}</TableHead>)}{actions && <TableHead scope=\"col\">{labels.actions}</TableHead>}</TableRow></TableHeader>\n {groups.map(group => <TableBody key={group.key}>\n {group.label !== undefined && <TableRow><TableHead data-record-group={group.key} scope=\"rowgroup\" colSpan={Math.max(1, fields.length + Number(actions))}>{group.label}</TableHead></TableRow>}\n {group.rows.map(row => <TableRow key={row.id} data-record-row={row.id}>{fields.map(column => {\n const value = recordValue(row, column.key);\n return <TableCell key={column.key} className={column.type === 'number' ? 'text-right' : undefined}>{column.type === 'status' && value !== null && typeof value === 'object' ? <Status status={value} /> : formatRecordValue(value, column.type, locale, labels.missing)}</TableCell>;\n })}{actions && <TableCell><div className=\"flex flex-wrap gap-2\">{row.actions?.map(action => <Button key={action.id} variant=\"outline\" className=\"min-h-11\" disabled={!onAction} onClick={() => onAction?.(action, row)}>{action.label}</Button>)}</div></TableCell>}</TableRow>)}\n </TableBody>)}\n </Table></div>;\n}\n\nexport interface RecordListItem {\n readonly id: string;\n readonly title: string;\n readonly detail?: string | undefined;\n readonly status?: RecordStatus | undefined;\n /** Labelled status columns when projecting typed rows. */\n readonly statuses?: readonly { readonly label: string; readonly value: RecordStatus }[] | undefined;\n readonly action?: RecordAction | undefined;\n /** Extra actions when projecting a row with more than one action into a list. */\n readonly actions?: readonly RecordAction[] | undefined;\n}\nexport interface RecordListProps extends Words {\n readonly items: readonly RecordListItem[];\n readonly onAction?: ((action: RecordAction, item: RecordListItem) => void) | undefined;\n}\n\n/** A compact status list. Detail remains wrapping text rather than a line-clamped shadcn description. */\nexport function RecordList({ items, onAction, locale, labels: given }: RecordListProps) {\n const labels = recordsLabelsFor(locale, given);\n if (!items.length) return <NoRecords label={labels.empty} />;\n return <ItemGroup data-boring=\"record-list\" aria-label={labels.kind}>{items.map(item => {\n const actions = [...(item.action ? [item.action] : []), ...(item.actions ?? [])];\n return <Item key={item.id} role=\"listitem\" data-record-item={item.id} variant=\"outline\" className=\"min-w-0\">\n <ItemContent className=\"min-w-0\"><div className=\"whitespace-normal wrap-anywhere\">{item.title}</div>\n {item.detail && <div className=\"whitespace-pre-wrap wrap-anywhere\">{item.detail}</div>}\n {item.status && <div className=\"max-w-full overflow-auto\"><Status status={item.status} /></div>}\n {item.statuses?.map((status, index) => <div key={index} className=\"max-w-full overflow-auto\"><Status status={status.value} label={status.label} /></div>)}\n </ItemContent>\n {!!actions.length && <ItemActions className=\"max-w-full flex-wrap overflow-auto\">{actions.map(action => <Button key={action.id} variant=\"outline\" className=\"min-h-11\" disabled={!onAction} onClick={() => onAction?.(action, item)}>{action.label}</Button>)}</ItemActions>}\n </Item>;\n })}</ItemGroup>;\n}\n\nexport interface RecordsViewerProps extends Words {\n readonly text: string;\n readonly onAction?: RecordTableProps['onAction'];\n}\n\n/** The phone projection preserves every column and action; domain status meaning stays with the app. */\nexport function RecordsViewer({ text, onAction, locale, labels: given }: RecordsViewerProps) {\n const parsed = useMemo(() => parseRecords(text), [text]);\n const labels = recordsLabelsFor(locale, given);\n if (parsed.kind !== 'ok') return <section data-boring=\"records-viewer\" data-state=\"invalid\" className=\"flex min-w-0 flex-col gap-3 p-3\">\n <Alert data-testid=\"records-invalid\"><AlertTitle>{labels.invalidTitle}</AlertTitle><AlertDescription>{labels.invalidNote}</AlertDescription></Alert>\n <pre data-testid=\"records-raw\" className=\"overflow-auto whitespace-pre-wrap wrap-anywhere\">{text}</pre>\n </section>;\n const { columns, rows, groupBy } = parsed.records;\n const groups = groupsOf(rows, columns, groupBy, locale, labels);\n const fields = columns.filter(column => column.key !== groupBy);\n const first = fields.find(column => column.type !== 'status');\n return <section data-boring=\"records-viewer\" data-state=\"ok\" aria-label={labels.kind} className=\"min-w-0 p-3\">\n <div className=\"hidden sm:block\"><RecordTable columns={columns} rows={rows} groupBy={groupBy} onAction={onAction} locale={locale} labels={given} /></div>\n <div data-testid=\"records-phone\" className=\"flex flex-col gap-4 sm:hidden\">{!rows.length ? <NoRecords label={labels.empty} /> : groups.map(group => {\n const items = group.rows.map(row => ({ id: row.id, title: first ? formatRecordValue(recordValue(row, first.key), first.type, locale, labels.missing) : row.id,\n detail: fields.filter(column => column.key !== first?.key && column.type !== 'status').map(column => `${column.label}: ${formatRecordValue(recordValue(row, column.key), column.type, locale, labels.missing)}`).join('\\n'),\n statuses: fields.filter(column => column.type === 'status').map(column => {\n const value = recordValue(row, column.key);\n return { label: column.label, value: value !== null && typeof value === 'object' ? value : { label: labels.missing, tone: 'neutral' as const } };\n }), actions: row.actions }));\n return <div key={group.key} className=\"flex min-w-0 flex-col gap-2\">{group.label !== undefined && <h3 data-record-list-group={group.key}>{group.label}</h3>}\n <RecordList items={items} locale={locale} labels={given} onAction={onAction ? (action, item) => { const row = group.rows.find(row => row.id === item.id); if (row) onAction(action, row); } : undefined} />\n </div>;\n })}</div>\n </section>;\n}\n\nexport interface RecordsKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\nexport function createRecordsKind(options: Words & { readonly onAction?: RecordTableProps['onAction'] } = {}): RecordsKind {\n return { match: target => isRecordsPath(target.resource.path), label: recordsLabelsFor(options.locale, options.labels).kind, icon: Rows3Icon,\n renderer: ({ text }) => <RecordsViewer text={text} {...options} /> };\n}\nexport const recordsKind: RecordsKind = createRecordsKind();\n",
34
+ "content": "'use client';\n\nimport { useMemo } from 'react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Empty, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Item, ItemActions, ItemContent, ItemGroup } from '@/components/ui/item';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport { formatRecordValue, parseRecords, recordValue } from './records';\nimport type { RecordAction, RecordColumn, RecordRow, RecordStatus, RecordTone } from './records';\nimport { recordsLabelsFor } from './records-labels';\nimport type { RecordsLabels, RecordsLocale } from './records-labels';\n\nexport { formatRecordValue, isRecordsPath, parseRecords } from './records';\nexport type { RecordAction, RecordColumn, RecordColumnType, RecordRow, RecordStatus, RecordTone, RecordValue, RecordsFile, RecordsParse } from './records';\nexport { defaultRecordsLabels, frenchRecordsLabels, recordsLabelsFor } from './records-labels';\nexport type { RecordsLabels, RecordsLocale } from './records-labels';\n\nconst variants = { ok: 'default', warn: 'secondary', error: 'destructive', info: 'outline', neutral: 'secondary' } as const satisfies Record<RecordTone, string>;\nfunction Status({ status, label }: { readonly status: RecordStatus; readonly label?: string | undefined }) {\n return <Badge data-record-status={status.tone} variant={variants[status.tone]} role={label ? 'img' : undefined} aria-label={label ? `${label}: ${status.label}` : undefined}>{status.label}</Badge>;\n}\nfunction NoRecords({ label }: { readonly label: string }) {\n return <Empty><EmptyHeader><EmptyTitle>{label}</EmptyTitle></EmptyHeader></Empty>;\n}\nexport interface Words {\n readonly locale?: RecordsLocale | undefined;\n readonly labels?: Partial<RecordsLabels> | undefined;\n}\nexport interface RecordTableProps extends Words {\n readonly columns: readonly RecordColumn[];\n readonly rows: readonly RecordRow[];\n readonly groupBy?: string | undefined;\n readonly onAction?: ((action: RecordAction, row: RecordRow) => void) | undefined;\n}\n\n/** Groups retain their first-seen order; the key is the typed raw value, never its rounded display label. */\nfunction groupsOf(rows: readonly RecordRow[], columns: readonly RecordColumn[], groupBy: string | undefined, locale: RecordsLocale | undefined, labels: RecordsLabels) {\n const column = columns.find(column => column.key === groupBy);\n if (!column) return [{ key: '', label: undefined, rows }];\n const groups = new Map<string, { key: string; label: string; rows: RecordRow[] }>();\n for (const row of rows) {\n const value = recordValue(row, column.key), key = JSON.stringify(value !== null && typeof value === 'object' ? [value.tone, value.label] : value);\n let group = groups.get(key);\n if (!group) { group = { key, label: `${column.label}: ${formatRecordValue(value, column.type, locale, labels.missing)}`, rows: [] }; groups.set(key, group); }\n group.rows.push(row);\n }\n return [...groups.values()];\n}\n\n/** A typed table of app data. Actions call the host only; their file metadata grants no permission. */\nexport function RecordTable({ columns, rows, groupBy, onAction, locale, labels: given }: RecordTableProps) {\n const labels = recordsLabelsFor(locale, given);\n const groups = groupsOf(rows, columns, groupBy, locale, labels);\n const fields = columns.filter(column => column.key !== groupBy);\n const actions = rows.some(row => row.actions?.length);\n if (!rows.length) return <NoRecords label={labels.empty} />;\n return <div data-boring=\"record-table\" className=\"min-w-0 overflow-auto\"><Table aria-label={labels.kind}>\n <TableHeader><TableRow>{fields.map(column => <TableHead key={column.key} scope=\"col\" className={column.type === 'number' ? 'text-right' : undefined}>{column.label}</TableHead>)}{actions && <TableHead scope=\"col\">{labels.actions}</TableHead>}</TableRow></TableHeader>\n {groups.map(group => <TableBody key={group.key}>\n {group.label !== undefined && <TableRow><TableHead data-record-group={group.key} scope=\"rowgroup\" colSpan={Math.max(1, fields.length + Number(actions))}>{group.label}</TableHead></TableRow>}\n {group.rows.map(row => <TableRow key={row.id} data-record-row={row.id}>{fields.map(column => {\n const value = recordValue(row, column.key);\n return <TableCell key={column.key} className={column.type === 'number' ? 'text-right' : undefined}>{column.type === 'status' && value !== null && typeof value === 'object' ? <Status status={value} /> : formatRecordValue(value, column.type, locale, labels.missing)}</TableCell>;\n })}{actions && <TableCell><div className=\"flex flex-wrap gap-2\">{row.actions?.map(action => <Button key={action.id} variant=\"outline\" className=\"min-h-11\" disabled={!onAction} onClick={() => onAction?.(action, row)}>{action.label}</Button>)}</div></TableCell>}</TableRow>)}\n </TableBody>)}\n </Table></div>;\n}\n\nexport interface RecordListItem {\n readonly id: string;\n readonly title: string;\n readonly detail?: string | undefined;\n readonly status?: RecordStatus | undefined;\n /** Labelled status columns when projecting typed rows. */\n readonly statuses?: readonly { readonly label: string; readonly value: RecordStatus }[] | undefined;\n readonly action?: RecordAction | undefined;\n /** Extra actions when projecting a row with more than one action into a list. */\n readonly actions?: readonly RecordAction[] | undefined;\n}\nexport interface RecordListProps extends Words {\n readonly items: readonly RecordListItem[];\n readonly onAction?: ((action: RecordAction, item: RecordListItem) => void) | undefined;\n}\n\n/** A compact status list. Detail remains wrapping text rather than a line-clamped shadcn description. */\nexport function RecordList({ items, onAction, locale, labels: given }: RecordListProps) {\n const labels = recordsLabelsFor(locale, given);\n if (!items.length) return <NoRecords label={labels.empty} />;\n return <ItemGroup data-boring=\"record-list\" aria-label={labels.kind}>{items.map(item => {\n const actions = [...(item.action ? [item.action] : []), ...(item.actions ?? [])];\n return <Item key={item.id} role=\"listitem\" data-record-item={item.id} variant=\"outline\" className=\"min-w-0\">\n <ItemContent className=\"min-w-0\"><div className=\"whitespace-normal wrap-anywhere\">{item.title}</div>\n {item.detail && <div className=\"whitespace-pre-wrap wrap-anywhere\">{item.detail}</div>}\n {item.status && <div className=\"max-w-full overflow-auto\"><Status status={item.status} /></div>}\n {item.statuses?.map((status, index) => <div key={index} className=\"max-w-full overflow-auto\"><Status status={status.value} label={status.label} /></div>)}\n </ItemContent>\n {!!actions.length && <ItemActions className=\"max-w-full flex-wrap overflow-auto\">{actions.map(action => <Button key={action.id} variant=\"outline\" className=\"min-h-11\" disabled={!onAction} onClick={() => onAction?.(action, item)}>{action.label}</Button>)}</ItemActions>}\n </Item>;\n })}</ItemGroup>;\n}\n\nexport interface RecordsViewerProps extends Words {\n readonly text: string;\n readonly onAction?: RecordTableProps['onAction'];\n}\n\n/** The phone projection preserves every column and action; domain status meaning stays with the app. */\nexport function RecordsViewer({ text, onAction, locale, labels: given }: RecordsViewerProps) {\n const parsed = useMemo(() => parseRecords(text), [text]);\n const labels = recordsLabelsFor(locale, given);\n if (parsed.kind !== 'ok') return <section data-boring=\"records-viewer\" data-state=\"invalid\" className=\"flex min-w-0 flex-col gap-3 p-3\">\n <Alert data-testid=\"records-invalid\"><AlertTitle>{labels.invalidTitle}</AlertTitle><AlertDescription>{labels.invalidNote}</AlertDescription></Alert>\n <pre data-testid=\"records-raw\" className=\"overflow-auto whitespace-pre-wrap wrap-anywhere\">{text}</pre>\n </section>;\n const { columns, rows, groupBy } = parsed.records;\n const groups = groupsOf(rows, columns, groupBy, locale, labels);\n const fields = columns.filter(column => column.key !== groupBy);\n const first = fields.find(column => column.type !== 'status');\n return <section data-boring=\"records-viewer\" data-state=\"ok\" aria-label={labels.kind} className=\"min-w-0 p-3\">\n <div className=\"hidden sm:block\"><RecordTable columns={columns} rows={rows} groupBy={groupBy} onAction={onAction} locale={locale} labels={given} /></div>\n <div data-testid=\"records-phone\" className=\"flex flex-col gap-4 sm:hidden\">{!rows.length ? <NoRecords label={labels.empty} /> : groups.map(group => {\n const items = group.rows.map(row => ({ id: row.id, title: first ? formatRecordValue(recordValue(row, first.key), first.type, locale, labels.missing) : row.id,\n detail: fields.filter(column => column.key !== first?.key && column.type !== 'status').map(column => `${column.label}: ${formatRecordValue(recordValue(row, column.key), column.type, locale, labels.missing)}`).join('\\n'),\n statuses: fields.filter(column => column.type === 'status').map(column => {\n const value = recordValue(row, column.key);\n return { label: column.label, value: value !== null && typeof value === 'object' ? value : { label: labels.missing, tone: 'neutral' as const } };\n }), actions: row.actions }));\n return <div key={group.key} className=\"flex min-w-0 flex-col gap-2\">{group.label !== undefined && <h3 data-record-list-group={group.key}>{group.label}</h3>}\n <RecordList items={items} locale={locale} labels={given} onAction={onAction ? (action, item) => { const row = group.rows.find(row => row.id === item.id); if (row) onAction(action, row); } : undefined} />\n </div>;\n })}</div>\n </section>;\n}\n",
35
35
  "type": "registry:component",
36
36
  "target": "components/record-viewer/record-viewer.tsx"
37
+ },
38
+ {
39
+ "path": "registry/record-viewer/records-kind.tsx",
40
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { Rows3Icon } from 'lucide-react';\nimport { isRecordsPath } from './records';\nimport { recordsLabelsFor } from './records-labels';\nimport type { RecordTableProps, Words } from './record-viewer';\n\nconst RecordsViewer = lazy(() => import('./record-viewer').then(module => ({ default: module.RecordsViewer })));\n\nexport interface RecordsKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n/** The `*.records.json` kind; the viewer loads on first open. */\nexport function createRecordsKind(options: Words & { readonly onAction?: RecordTableProps['onAction'] } = {}): RecordsKind {\n return { match: target => isRecordsPath(target.resource.path), label: recordsLabelsFor(options.locale, options.labels).kind, icon: Rows3Icon,\n renderer: ({ text }) => <RecordsViewer text={text} {...options} /> };\n}\nexport const recordsKind: RecordsKind = createRecordsKind();\n",
41
+ "type": "registry:component",
42
+ "target": "components/record-viewer/records-kind.tsx"
37
43
  }
38
44
  ],
39
45
  "type": "registry:component"