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:
committed by
brooklyn!
parent
9ed4a7c025
commit
59b1c40cdf
@@ -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  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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user