diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 829b230a5b..6b24735d44 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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; " diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 88b8ae7513..c0b76919e8 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 ? : null) + } satisfies TranscriptDirectiveContribution + }, { id: 'layout.reset', area: PALETTE_AREA, diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2158e54662..b2e6ba0c2b 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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 `

` — 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 + } + + 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. +

+ {children} +

+ ) +} + function MarkdownTextSurface({ containerClassName, containerProps, @@ -493,12 +524,7 @@ function MarkdownTextSurface({ h4: ({ className, ...props }: ComponentProps<'h4'>) => (

), - 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: (props: ComponentProps<'p'>) => , a: MarkdownLink, // Inline code must not vote when an ancestor resolves `dir="auto"` // (HTML's algorithm skips descendants that carry their own dir), diff --git a/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx b/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx new file mode 100644 index 0000000000..8e0afd9230 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx @@ -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 ', bold])).toBeNull() + expect(paragraphPlainText(null)).toBeNull() + expect(paragraphPlainText([])).toBeNull() + }) +}) + +describe('TranscriptDirectiveLeaf', () => { + afterEach(cleanup) + + const contribution = (over?: Partial) => + registry.register({ + id: 'test:demo', + area: TRANSCRIPT_DIRECTIVE_AREA, + source: 'plugin:test', + data: { + name: 'demo', + render: ({ attrs }) =>

{attrs.label ?? 'demo'}
, + ...over + } satisfies TranscriptDirectiveContribution + }) + + it('renders the registered component for a claimed directive', () => { + const dispose = contribution() + + try { + render() + expect(screen.getByTestId('demo-card').textContent).toBe('hi') + } finally { + dispose() + } + }) + + it('renders nothing for an unclaimed directive', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('renders nothing for plain prose', () => { + const { container } = render() + + 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() + // The chip fallback renders the contribution id, not a dead subtree. + expect(screen.getByRole('button')).toBeTruthy() + } finally { + dispose() + } + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/transcript-directive.tsx b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx new file mode 100644 index 0000000000..b3904515a5 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx @@ -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 `

` — 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 ( + + + contribution.render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false }) + } + /> + + ) +} + +/** True when the paragraph text will resolve to a registered directive — + * callers that must decide `

` 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 +} diff --git a/apps/desktop/src/lib/transcript-directives.test.ts b/apps/desktop/src/lib/transcript-directives.test.ts new file mode 100644 index 0000000000..b40f0838b6 --- /dev/null +++ b/apps/desktop/src/lib/transcript-directives.test.ts @@ -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')).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() + }) +}) diff --git a/apps/desktop/src/lib/transcript-directives.ts b/apps/desktop/src/lib/transcript-directives.ts new file mode 100644 index 0000000000..d0629f4b8a --- /dev/null +++ b/apps/desktop/src/lib/transcript-directives.ts @@ -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> + /** 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: `::{...}`. 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 + 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 = {} + + 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 } +} diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index af75b6a7b3..82f60bfba9 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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 diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 04cfbd3a53..305c2ce727 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -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. diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 06fd4baa1b..03331b6af9 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -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