import type { EntityRef, ComposerDoc } from '../primitives/composer-model'; import { normalizeValue } from '../primitives/composer-model'; export const ZWSP = '​'; export const ENTITY_ATTR = 'data-kai-entity'; export const entityStore = new WeakMap(); export function isEntityEl(node: Node | null): node is HTMLElement { return !!node && node.nodeType === 1 && (node as HTMLElement).hasAttribute(ENTITY_ATTR); } /** * A TreeWalker over the editable's text nodes that SKIPS text inside entity * pills. Pills are atomic and contribute nothing to the text model — their inner * label text must not pollute caret offsets, trigger detection, or highlight * ranges. (Without this, the text seen at a caret right after a pill is the * pill's label, so `/` reads as glued to the label instead of starting a token.) */ export function createTextWalker(root: HTMLElement): TreeWalker { return root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { let p = node.parentElement; while (p && p !== root) { if (p.hasAttribute(ENTITY_ATTR)) return NodeFilter.FILTER_REJECT; p = p.parentElement; } return NodeFilter.FILTER_ACCEPT; }, }); } /** * The leading sigil shown on a "light" pill, by kind: skills `/`, agents `@`. * Skills and agents render as decorated inline text led by their sigil; this is * what makes them read like `/my-skill` / `@my-agent` rather than a chip. * Returns '' for kinds rendered as a richer CHIP (plugins, and any other/unknown * kind) — those carry an icon instead of a sigil. The sigil is visual only: it * never enters the text model (the entity is read from `entityStore`, and the * text walker skips pill-internal nodes). */ export function kindSigil(kind: string): string { switch (kind) { case 'skill': return '/'; case 'agent': return '@'; default: return ''; } } /** * Default monochrome (currentColor) glyph per entity kind, for the richer CHIP * kinds shown in the trigger menu / on plugin pills: plugin = plug. (Skills and * agents are light sigil-text pills — see `kindSigil` — so they take no glyph on * the pill, though the menu may still show one.) Returns inline SVG markup * (trusted — no user input), or '' for kinds without a default. An item's own * `icon` always takes precedence over this. */ export function kindGlyph(kind: string): string { const svg = (inner: string) => ``; switch (kind) { case 'agent': return svg(''); case 'plugin': return svg(''); default: return ''; // skills + unknown kinds: no default glyph } } export function createEntityEl( doc: Document, entity: EntityRef, kindIcons?: Record, ): HTMLElement { const el = doc.createElement('span'); el.setAttribute(ENTITY_ATTR, ''); el.setAttribute('contenteditable', 'false'); el.dataset.kind = entity.kind; el.dataset.id = entity.id; el.className = 'kai-composer-pill'; const sigil = kindSigil(entity.kind); if (sigil) { // Skills/agents: LIGHT pill — decorated inline text led by the sigil, no // icon. (`/my-skill`, `@my-agent`.) const s = doc.createElement('span'); s.className = 'kai-composer-pill-sigil'; s.setAttribute('aria-hidden', 'true'); s.textContent = sigil; el.appendChild(s); } else { // Plugins (and other composite/unknown kinds): the richer CHIP, with an // icon so a bundle reads differently from a skill/agent at a glance. Icon // resolution: the item's own icon → per-kind default (kindIcons) → a // built-in kind glyph (plugin) → nothing. const iconSrc = entity.icon ?? kindIcons?.[entity.kind]; if (iconSrc) { const img = doc.createElement('img'); img.src = iconSrc; img.alt = ''; img.className = 'kai-composer-pill-icon'; el.appendChild(img); } else { const glyph = kindGlyph(entity.kind); if (glyph) { const span = doc.createElement('span'); span.className = 'kai-composer-pill-icon kai-composer-pill-glyph'; span.setAttribute('aria-hidden', 'true'); span.innerHTML = glyph; // trusted SVG markup (not user input) el.appendChild(span); } } } el.appendChild(doc.createTextNode(entity.label)); entityStore.set(el, entity); return el; } export function parseDom(root: HTMLElement): ComposerDoc { const segs: ComposerDoc = []; const nodes = root.childNodes; nodes.forEach((node, i) => { if (isEntityEl(node)) { const stored = entityStore.get(node as HTMLElement); const el = node as HTMLElement; segs.push({ type: 'entity', entity: stored ?? { kind: el.dataset.kind ?? '', id: el.dataset.id ?? '', label: el.textContent ?? '' } }); } else if (node.nodeType === 1 && (node as HTMLElement).tagName === 'BR') { // Drop the browser's trailing filler
— contenteditable inserts one when // the field is cleared (or after a newline) to keep the line visible. Treating // it as content would leave the field "non-empty" (placeholder stuck hidden) // and serialize a phantom trailing "\n". A
BETWEEN content is a real // newline and is preserved. if (i === nodes.length - 1) return; segs.push({ type: 'text', text: '\n' }); } else if (node.nodeType === 3) { segs.push({ type: 'text', text: (node.textContent ?? '').split(ZWSP).join('') }); } }); return normalizeValue(segs); } export function renderDoc( root: HTMLElement, doc: ComposerDoc, ownerDoc: Document = document, kindIcons?: Record, ): void { root.textContent = ''; for (const seg of doc) { if (seg.type === 'text') root.appendChild(ownerDoc.createTextNode(seg.text)); else { root.appendChild(createEntityEl(ownerDoc, seg.entity, kindIcons)); root.appendChild(ownerDoc.createTextNode(ZWSP)); } } }