@hachej/boring-ui-kit 0.4.2 → 0.4.3
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.
- package/README.md +3 -1
- package/bin/boring-ui-blocks.mjs +29 -0
- package/dist/canvas-editor-native.d.ts +8 -1
- package/dist/canvas-editor-native.js +13 -7
- package/dist/canvas-editor.d.ts +4 -2
- package/dist/canvas-editor.js +5 -0
- package/dist/file-tree-view.d.ts +29 -1
- package/dist/file-tree-view.js +74 -7
- package/package.json +2 -2
- package/r/app-shell.json +7 -7
- package/r/board-viewer.json +49 -0
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +42 -0
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +7 -6
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +10 -13
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +45 -0
- package/r/pi-ambient.json +6 -6
- package/r/pi-app.json +20 -18
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/registry.json +203 -81
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +7 -7
package/r/pi-chat.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Pi chat",
|
|
5
5
|
"description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history, a human-in-the-loop question card, an Approve / Deny card for host tools wrapped with requireApproval optional artifact cards that open generated content in the host's panel, 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 optional pi-ambient and pi-workspace blocks build on it.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.3",
|
|
8
|
+
"@hachej/boring-files@^0.4.3",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"@shadcn/react@0.3.1"
|
|
20
20
|
],
|
|
21
21
|
"registryDependencies": [
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
23
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/button.json",
|
|
24
24
|
"trace",
|
|
25
25
|
"message",
|
|
26
26
|
"message-scroller",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "registry/pi-chat/artifact.ts",
|
|
74
|
-
"content": "import type { ConversationView } from '@earendil-works/pi-durable';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ComponentType, ReactNode } from 'react';\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, and 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).\n */\nexport interface ArtifactKindViewProps { readonly artifact: ArtifactDescriptor; readonly text: string; readonly fragment?: string | undefined }\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\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}\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\nconst TYPE_LABEL: Record<ArtifactType, string> = { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' };\n/** The artifact's type name: the matching kind's label first, then the built-in name of its type. */\nexport function typeLabel(artifact: ArtifactDescriptor, kinds?: readonly ArtifactKind[]): string {\n const kind = artifactKind(artifact, kinds);\n if (kind) return kind.label;\n return artifact.type === 'code' && artifact.language ? `Code · ${artifact.language}` : TYPE_LABEL[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 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 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",
|
|
74
|
+
"content": "import type { ConversationView } from '@earendil-works/pi-durable';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ComponentType, ReactNode } from 'react';\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 /** Async built-in viewers call this only once their document is loaded. */\n readonly onReady?: (() => 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\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}\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\nconst TYPE_LABEL: Record<ArtifactType, string> = { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' };\n/** The artifact's type name: the matching kind's label first, then the built-in name of its type. */\nexport function typeLabel(artifact: ArtifactDescriptor, kinds?: readonly ArtifactKind[]): string {\n const kind = artifactKind(artifact, kinds);\n if (kind) return kind.label;\n return artifact.type === 'code' && artifact.language ? `Code · ${artifact.language}` : TYPE_LABEL[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 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 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",
|
|
75
75
|
"type": "registry:lib",
|
|
76
76
|
"target": "components/pi-chat/artifact.ts"
|
|
77
77
|
},
|
package/r/pi-workspace.json
CHANGED
|
@@ -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
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/dock.json",
|
|
15
15
|
"badge",
|
|
16
16
|
"card"
|
|
17
17
|
],
|
package/r/provider-setup.json
CHANGED
|
@@ -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.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
|
|
14
14
|
"button",
|
|
15
15
|
"input",
|
|
16
16
|
"label",
|