feat(desktop): plugins can render inline components in assistant messages via ::name{...} directives

The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.

Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
This commit is contained in:
Brooklyn Nicholson
2026-08-16 19:11:06 -05:00
committed by brooklyn!
parent 9ed4a7c025
commit 59b1c40cdf
10 changed files with 386 additions and 6 deletions
+4
View File
@@ -927,6 +927,10 @@ PLATFORM_HINTS = {
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format ![alt](url) and they "
"render inline as photos. "
"To show a workspace file as a live inline preview card (an HTML page "
"you built, a report), put ::preview{file=\"path/to/file.html\"} alone "
"on its own line — desktop plugins can register more ::name{...} "
"directives like it. "
"When the user asks to add, enable, or authorize an MCP server (or a "
"task clearly needs one that is missing), use the setup_mcp tool if "
"it is available — it shows an inline consent card right in the chat; "
@@ -6,6 +6,7 @@ import { SessionDraftTitle } from '@/app/chat/session-draft-title'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { IdleMount } from '@/components/idle-mount'
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
@@ -40,6 +41,7 @@ import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
import { setYoloEnabled } from '@/lib/yolo-session'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
import {
@@ -274,6 +276,18 @@ registry.registerMany([
run: () => void discoverRuntimePlugins()
} satisfies PaletteContribution
},
// The core `::preview{file="…"}` transcript directive — the model (or a
// skill) addresses the existing preview card deliberately instead of
// relying on artifact/fence heuristics. Also the reference consumer for
// the `transcript.directives` area plugins register into.
{
id: 'transcript.preview',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'preview',
render: ({ attrs }) => (attrs.file ? <PreviewAttachment source="explicit-link" target={attrs.file} /> : null)
} satisfies TranscriptDirectiveContribution
},
{
id: 'layout.reset',
area: PALETTE_AREA,
@@ -38,6 +38,7 @@ import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive'
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
@@ -462,6 +463,36 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
)
}
/**
* Paragraph override. Almost always a plain `<p>` — but a paragraph that is
* exactly one `::name{...}` directive claimed by a plugin renders as that
* plugin's transcript component instead (`transcript.directives` area). The
* claim check subscribes to the registry, so hot-loading a plugin upgrades
* already-rendered directives in place; unclaimed directives stay prose.
*/
function MarkdownParagraph({
children,
className,
streaming,
...props
}: ComponentProps<'p'> & { streaming?: boolean }) {
const plain = paragraphPlainText(children)
const claimed = useIsClaimedDirective(plain)
if (claimed && plain !== null) {
return <TranscriptDirectiveLeaf streaming={streaming} text={plain} />
}
return (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props}>
{children}
</p>
)
}
function MarkdownTextSurface({
containerClassName,
containerProps,
@@ -493,12 +524,7 @@ function MarkdownTextSurface({
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
import { paragraphPlainText, TranscriptDirectiveLeaf } from './transcript-directive'
describe('paragraphPlainText', () => {
it('passes through a plain string', () => {
expect(paragraphPlainText('::tasks')).toBe('::tasks')
})
it('joins an all-string child array (streamed text chunks)', () => {
expect(paragraphPlainText(['::preview{file=', '"a.html"}'])).toBe('::preview{file="a.html"}')
})
it('disqualifies paragraphs with element children', () => {
expect(paragraphPlainText(['::tasks ', <b key="x">bold</b>])).toBeNull()
expect(paragraphPlainText(null)).toBeNull()
expect(paragraphPlainText([])).toBeNull()
})
})
describe('TranscriptDirectiveLeaf', () => {
afterEach(cleanup)
const contribution = (over?: Partial<TranscriptDirectiveContribution>) =>
registry.register({
id: 'test:demo',
area: TRANSCRIPT_DIRECTIVE_AREA,
source: 'plugin:test',
data: {
name: 'demo',
render: ({ attrs }) => <div data-testid="demo-card">{attrs.label ?? 'demo'}</div>,
...over
} satisfies TranscriptDirectiveContribution
})
it('renders the registered component for a claimed directive', () => {
const dispose = contribution()
try {
render(<TranscriptDirectiveLeaf text='::demo{label="hi"}' />)
expect(screen.getByTestId('demo-card').textContent).toBe('hi')
} finally {
dispose()
}
})
it('renders nothing for an unclaimed directive', () => {
const { container } = render(<TranscriptDirectiveLeaf text="::nobody-home" />)
expect(container.firstChild).toBeNull()
})
it('renders nothing for plain prose', () => {
const { container } = render(<TranscriptDirectiveLeaf text="just some text" />)
expect(container.firstChild).toBeNull()
})
it('contains a throwing plugin render to its own boundary', () => {
const dispose = contribution({
render: () => {
throw new Error('plugin bug')
}
})
try {
render(<TranscriptDirectiveLeaf text="::demo" />)
// The chip fallback renders the contribution id, not a dead subtree.
expect(screen.getByRole('button')).toBeTruthy()
} finally {
dispose()
}
})
})
@@ -0,0 +1,70 @@
import type { FC, ReactNode } from 'react'
import { type Contribution, useContributions } from '@/contrib'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import {
parseTranscriptDirective,
TRANSCRIPT_DIRECTIVE_AREA,
type TranscriptDirectiveContribution
} from '@/lib/transcript-directives'
/**
* The transcript's directive slot. Given a paragraph's raw text, renders the
* registered plugin component when the whole paragraph is a claimed
* `::name{...}` directive; returns null otherwise so the caller keeps its
* plain `<p>` — an unclaimed directive is just prose.
*
* Resolution is registry-backed (`transcript.directives`), so hot-loading a
* plugin upgrades already-rendered paragraphs in place, exactly like every
* other contribution area.
*/
/** Extract the paragraph's text when it is text-only — directives never carry
* inline markup, so any non-string child disqualifies the paragraph. */
export function paragraphPlainText(children: ReactNode): string | null {
if (typeof children === 'string') {
return children
}
if (Array.isArray(children) && children.length > 0 && children.every(child => typeof child === 'string')) {
return children.join('')
}
return null
}
/** The contribution claiming `name`, if any. First registration wins. */
function claimFor(contributions: readonly Contribution[], name: string) {
return contributions.find(c => (c.data as TranscriptDirectiveContribution | undefined)?.name === name)
}
export const TranscriptDirectiveLeaf: FC<{ text: string; streaming?: boolean }> = ({ text, streaming }) => {
const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
const parsed = parseTranscriptDirective(text)
const match = parsed ? claimFor(contributions, parsed.name) : undefined
const contribution = match?.data as TranscriptDirectiveContribution | undefined
if (!parsed || !match || !contribution?.render) {
return null
}
return (
<ContribBoundary id={match.id} variant="chip">
<ContribRender
render={() =>
contribution.render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false })
}
/>
</ContribBoundary>
)
}
/** True when the paragraph text will resolve to a registered directive —
* callers that must decide `<p>` vs slot before rendering use this with the
* same registry snapshot the leaf reads. */
export function useIsClaimedDirective(text: string | null): boolean {
const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
const parsed = text === null ? null : parseTranscriptDirective(text)
return parsed !== null && claimFor(contributions, parsed.name) !== undefined
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { parseTranscriptDirective } from './transcript-directives'
describe('parseTranscriptDirective', () => {
it('parses a bare directive with no attributes', () => {
expect(parseTranscriptDirective('::tasks')).toEqual({ name: 'tasks', attrs: {}, source: '::tasks' })
})
it('parses double-quoted attributes', () => {
expect(parseTranscriptDirective('::preview{file="demo.html"}')).toEqual({
name: 'preview',
attrs: { file: 'demo.html' },
source: '::preview{file="demo.html"}'
})
})
it('parses multiple attributes and accepts single quotes', () => {
expect(parseTranscriptDirective(`::vis{file="a b.html" height='480'}`)?.attrs).toEqual({
file: 'a b.html',
height: '480'
})
})
it('lowercases attribute keys but preserves values', () => {
expect(parseTranscriptDirective('::vis{File="A.html"}')?.attrs).toEqual({ file: 'A.html' })
})
it('tolerates surrounding whitespace', () => {
expect(parseTranscriptDirective(' ::tasks{id="1"} ')?.name).toBe('tasks')
})
it('rejects prose containing a directive mid-text', () => {
expect(parseTranscriptDirective('see ::preview{file="x.html"} above')).toBeNull()
})
it('rejects multi-line paragraphs', () => {
expect(parseTranscriptDirective('::preview{file="x.html"}\nmore')).toBeNull()
})
it('rejects C++ scope-resolution lookalikes', () => {
expect(parseTranscriptDirective('::std')).toEqual({ name: 'std', attrs: {}, source: '::std' })
expect(parseTranscriptDirective('std::vector<int>')).toBeNull()
expect(parseTranscriptDirective('::Vector')).toBeNull()
})
it('rejects unquoted attribute values', () => {
expect(parseTranscriptDirective('::preview{file=demo.html}')?.attrs).toEqual({})
})
it('bounds pathological input instead of scanning it', () => {
expect(parseTranscriptDirective(`::x{${'a="b" '.repeat(400)}}`)).toBeNull()
})
})
@@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
/**
* TRANSCRIPT DIRECTIVES — the transcript as a contribution area.
*
* A plugin registers a named directive; the model addresses it by emitting a
* paragraph of the form `::name{key="value"}` and that leaf renders as the
* plugin's component, inline in the assistant message. This is the deliberate
* counterpart to artifact promotion: artifacts are heuristic (substantial
* fences get promoted whether or not the model asked), directives are
* addressed (nothing renders unless a plugin claimed the name).
*
* The parse is deliberately narrow — a directive must be the entire
* paragraph, so it can never hijack mid-prose text, and an unclaimed or
* malformed directive falls back to the plain paragraph it always was.
* Attributes are untrusted model output: plugins validate their own fields.
*/
export const TRANSCRIPT_DIRECTIVE_AREA = 'transcript.directives'
/** Props handed to a directive contribution's `render`. */
export interface TranscriptDirectiveProps {
/** Parsed, untrusted attributes (e.g. `{ file: 'demo.html' }`). */
attrs: Readonly<Record<string, string>>
/** Original directive source text (diagnostics / fallback rendering). */
source: string
/** True while the surrounding message is still streaming. */
streaming: boolean
}
/** Payload of a `transcript.directives` contribution's `data`. */
export interface TranscriptDirectiveContribution {
/** The name the model addresses: `::<name>{...}`. Lowercase, `[a-z0-9-]`,
* unique across plugins — first registration wins on collision. */
name: string
/** Renders the directive leaf. Mounted inside the contribution error
* boundary, so a throw degrades to an inline error, not a dead message. */
render: (props: TranscriptDirectiveProps) => ReactNode
}
export interface ParsedTranscriptDirective {
name: string
attrs: Record<string, string>
source: string
}
// The whole paragraph, nothing else on the line: `::name` or `::name{...}`.
// Length caps bound the attr scan on adversarial input.
const DIRECTIVE_RE = /^::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?$/
// `key="value"` pairs; single quotes accepted for model sloppiness.
const ATTR_RE = /([a-z][\w-]{0,63})=(?:"([^"]*)"|'([^']*)')/gi
/**
* Parse a paragraph as a transcript directive. Returns null unless the ENTIRE
* trimmed text is one directive — prose containing `::` stays prose.
* Pure and synchronous — safe to call during render.
*/
export function parseTranscriptDirective(text: string): ParsedTranscriptDirective | null {
const trimmed = text.trim()
// Cheap reject before the regex: directives are short single lines.
if (!trimmed.startsWith('::') || trimmed.length > 1200 || trimmed.includes('\n')) {
return null
}
const match = DIRECTIVE_RE.exec(trimmed)
if (!match) {
return null
}
const attrs: Record<string, string> = {}
if (match[2]) {
for (const pair of match[2].matchAll(ATTR_RE)) {
attrs[pair[1].toLowerCase()] = pair[2] ?? pair[3] ?? ''
}
}
return { name: match[1], attrs, source: trimmed }
}
+7
View File
@@ -457,6 +457,13 @@ export {
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
/** The transcript as a contribution area: register a named `::directive{...}`
* and the model can render your component inline in assistant messages. */
export {
TRANSCRIPT_DIRECTIVE_AREA,
type TranscriptDirectiveContribution,
type TranscriptDirectiveProps
} from '@/lib/transcript-directives'
/** THE full per-toolset config panel core Settings renders — provider picker,
* env vars / API keys, model catalog picker, and post-setup runners. Route-
* decoupled (the "manage keys" deep link is a no-op outside the router); pass
@@ -93,6 +93,14 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
`ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } })`
(renders below Artifacts, lights up at the route) — and/or a
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
- TRANSCRIPT directives: register `area: TRANSCRIPT_DIRECTIVE_AREA` with
`data: { name: 'task', render: ({ attrs, streaming }) => jsx(...) }` and
the assistant can render your component inline in a chat message by
emitting `::task{id="BB-12"}` alone on its own line. Attrs are untrusted
`key="value"` strings — validate them. Unclaimed/malformed directives fall
back to plain text; core's own `::preview{file="…"}` is the reference.
After registering one, TELL the model it exists (a bundled skill or the
user's instructions) — it won't discover the name on its own.
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
- `ctx.os` — the curated OS door, attributed to your plugin:
`ctx.os.notify({ title, body?, silent? })` posts a native OS notification.
@@ -349,6 +349,42 @@ ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
attachment source, or transform a draft before it is sent (`ComposerMiddleware`
with a `handler(draft) => draft | null`).
### Transcript directives — inline components the model addresses
`TRANSCRIPT_DIRECTIVE_AREA` makes the transcript itself a contribution area.
Register a named directive and the agent can render your component inline in
an assistant message by emitting a paragraph of the form `::name{key="value"}`:
```javascript
import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'
ctx.register({
id: 'task-card',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'task', // the model writes ::task{id="BB-12"}
render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
}
})
```
Rules the host enforces so the surface stays safe:
- The directive must be the **entire paragraph** — `::name` mid-prose stays
prose, so plugin components can never hijack running text.
- Attributes are **untrusted model output** (`key="value"` pairs, string-only).
Validate your own fields; render nothing on garbage rather than guessing.
- An **unclaimed** directive (no plugin registered for the name) renders as
the plain paragraph it always was — nothing breaks when a plugin is off.
- Renders are wrapped in the contribution error boundary: a throw degrades to
an inline error chip, never a dead message.
- First registration wins on a name collision; namespace adventurous names
with your slug (`myplugin-board`, not `board`).
Core ships one directive as the reference consumer: `::preview{file="…"}`
renders the standard preview-attachment card for a workspace file. Tell the
agent about your directive in a skill (that's how it learns to emit it).
### Mount-scoped chrome (`Contribute`)
`ctx.register` is for **permanent** contributions. When chrome should live and