| 1 | // Atomic `@mention` chips. The document carries an opaque sentinel (`⟦mention:N⟧`) per mention; a |
| 2 | // chip decoration paints it as an atomic widget (caret skips it, Backspace deletes it whole) and a |
| 3 | // side registry keyed by the id holds the payload — so an edit elsewhere can't corrupt it. On |
| 4 | // submit `serializeMentions` expands each sentinel to its `@token` (a path or source range), which |
| 5 | // is exactly what the consumer reads. |
| 6 | import type { Extension } from "@codemirror/state"; |
| 7 | import { Decoration, EditorView, WidgetType } from "@codemirror/view"; |
| 8 | |
| 9 | import { chipField } from "./chips.ts"; |
| 10 | |
| 11 | // Kept as two statements: kiln's type-stripping rejects inline `type` specifiers in re-exports. |
| 12 | export { mentionTriggerAt } from "./triggers.ts"; |
| 13 | export type { MentionTrigger } from "./triggers.ts"; |
| 14 | |
| 15 | /** A mention target surfaced by a provider: a workspace file or a code symbol. */ |
| 16 | export interface MentionCandidate { |
| 17 | kind: "file" | "symbol"; |
| 18 | /** Primary text on the chip and picker row (a basename or symbol name). */ |
| 19 | label: string; |
| 20 | /** Secondary muted text on the picker row (the directory, or a container). */ |
| 21 | detail?: string; |
| 22 | /** The `@token` this mention expands to in the serialized text (a path or source range). */ |
| 23 | token: string; |
| 24 | /** Optional image URL resolved by the host for file mentions. */ |
| 25 | icon?: string; |
| 26 | /** Compact source range shown beside the basename, such as `L12-18`. */ |
| 27 | lineLabel?: string; |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * The client/server seam. The editor asks a provider for candidates as the user types `@query`; a |
| 32 | * demo fakes it with a fixed list, a real host backs it with workspace file/symbol search. The UI |
| 33 | * depends only on this interface, never on how the results are found. |
| 34 | */ |
| 35 | export interface MentionProvider { |
| 36 | search(query: string): Promise<MentionCandidate[]>; |
| 37 | } |
| 38 | |
| 39 | /** A fixed-list provider for demos and tests: substring-filters a static candidate set. */ |
| 40 | export function staticMentionProvider(all: MentionCandidate[]): MentionProvider { |
| 41 | return { |
| 42 | search(query) { |
| 43 | const needle = query.toLowerCase(); |
| 44 | const hits = all.filter((c) => |
| 45 | `${c.label} ${c.token} ${c.detail ?? ""}`.toLowerCase().includes(needle), |
| 46 | ); |
| 47 | return Promise.resolve(hits.slice(0, 8)); |
| 48 | }, |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | const SENTINEL = /⟦mention:(\d+)⟧/g; |
| 53 | |
| 54 | /** |
| 55 | * Per-editor payload store keyed by the doc's `⟦mention:N⟧` sentinels. Scoped to one editor so the |
| 56 | * ids never collide across instances and the map dies with the editor instead of growing for the |
| 57 | * life of the module. |
| 58 | */ |
| 59 | export interface MentionRegistry { |
| 60 | add(mention: MentionCandidate): number; |
| 61 | get(id: number): MentionCandidate | undefined; |
| 62 | } |
| 63 | |
| 64 | export function createMentionRegistry(): MentionRegistry { |
| 65 | const items = new Map<number, MentionCandidate>(); |
| 66 | let nextId = 0; |
| 67 | return { |
| 68 | add(mention) { |
| 69 | const id = nextId++; |
| 70 | items.set(id, mention); |
| 71 | return id; |
| 72 | }, |
| 73 | get: (id) => items.get(id), |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | const FILE_SVG = |
| 78 | '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M4 2h4l3 3v9H4z"/><path d="M8 2v3h3"/></svg>'; |
| 79 | const SYMBOL_SVG = |
| 80 | '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 2.5c-1.2 0-1.5.8-1.5 2v1.3c0 .9-.4 1.7-1.5 1.7 1.1 0 1.5.8 1.5 1.7V11c0 1.2.3 2 1.5 2"/><path d="M9.5 2.5c1.2 0 1.5.8 1.5 2v1.3c0 .9.4 1.7 1.5 1.7-1.1 0-1.5.8-1.5 1.7V11c0 1.2-.3 2-1.5 2"/></svg>'; |
| 81 | |
| 82 | // No TS parameter properties here: kiln imports this module and its type-stripping cannot |
| 83 | // transform them ([`arc/verify/kiln-migration.md`](../../../../arc/verify/kiln-migration.md)). |
| 84 | class MentionWidget extends WidgetType { |
| 85 | readonly id: number; |
| 86 | readonly mention: MentionCandidate; |
| 87 | |
| 88 | constructor(id: number, mention: MentionCandidate) { |
| 89 | super(); |
| 90 | this.id = id; |
| 91 | this.mention = mention; |
| 92 | } |
| 93 | |
| 94 | override eq(other: MentionWidget): boolean { |
| 95 | return other.id === this.id; |
| 96 | } |
| 97 | |
| 98 | override toDOM(): HTMLElement { |
| 99 | const chip = document.createElement("span"); |
| 100 | chip.className = `chip is-${this.mention.kind}`; |
| 101 | chip.title = this.mention.token; |
| 102 | const icon = document.createElement("span"); |
| 103 | icon.className = "chip-icon"; |
| 104 | if (this.mention.icon) { |
| 105 | const image = document.createElement("img"); |
| 106 | image.alt = ""; |
| 107 | image.src = this.mention.icon; |
| 108 | icon.append(image); |
| 109 | } else { |
| 110 | icon.innerHTML = this.mention.kind === "symbol" ? SYMBOL_SVG : FILE_SVG; |
| 111 | } |
| 112 | const label = document.createElement("span"); |
| 113 | label.className = "chip-label"; |
| 114 | label.textContent = this.mention.label; |
| 115 | chip.append(icon, label); |
| 116 | if (this.mention.lineLabel) { |
| 117 | const lines = document.createElement("span"); |
| 118 | lines.className = "chip-detail"; |
| 119 | lines.textContent = this.mention.lineLabel; |
| 120 | chip.append(lines); |
| 121 | } |
| 122 | return chip; |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /** The sentinel occurrences in `doc` that have a registered payload. */ |
| 127 | export function scanMentions( |
| 128 | registry: MentionRegistry, |
| 129 | doc: string, |
| 130 | ): Array<{ from: number; to: number; id: number; mention: MentionCandidate }> { |
| 131 | const out: Array<{ from: number; to: number; id: number; mention: MentionCandidate }> = []; |
| 132 | for (const match of doc.matchAll(SENTINEL)) { |
| 133 | const id = Number(match[1]); |
| 134 | const mention = registry.get(id); |
| 135 | if (mention && match.index !== undefined) { |
| 136 | out.push({ from: match.index, to: match.index + match[0].length, id, mention }); |
| 137 | } |
| 138 | } |
| 139 | return out; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Structure only, and deliberately no paint. This package is the general-purpose editor, not the |
| 144 | * agent chat UI: it lays a chip out so the widget is legible standalone and leaves every border, |
| 145 | * surface, colour, and size to whoever themes the `chip`/`chip-icon`/`chip-label`/`chip-detail` |
| 146 | * contract this DOM emits. **A theme injected by CodeMirror carries a generated class and so |
| 147 | * outranks a plain product rule** — anything painted here would win inside the editor and lose |
| 148 | * everywhere else, which is one chip drawn two ways. |
| 149 | */ |
| 150 | const mentionTheme = EditorView.theme({ |
| 151 | ".chip": { |
| 152 | display: "inline-flex", |
| 153 | alignItems: "center", |
| 154 | gap: "0.3rem", |
| 155 | maxWidth: "16rem", |
| 156 | verticalAlign: "baseline", |
| 157 | }, |
| 158 | ".chip-icon": { display: "inline-flex", flex: "0 0 auto", alignSelf: "center" }, |
| 159 | ".chip-icon svg, .chip-icon img": { display: "block", height: "1em", width: "1em" }, |
| 160 | ".chip-label": { |
| 161 | minWidth: "0", |
| 162 | overflow: "hidden", |
| 163 | textOverflow: "ellipsis", |
| 164 | whiteSpace: "nowrap", |
| 165 | }, |
| 166 | ".chip-detail": { flex: "0 0 auto" }, |
| 167 | }); |
| 168 | |
| 169 | export function mentionsExtension(registry: MentionRegistry): Extension { |
| 170 | return [ |
| 171 | chipField({ |
| 172 | atomic: true, |
| 173 | chips: (state) => |
| 174 | scanMentions(registry, state.doc.toString()).map(({ from, to, id, mention }) => |
| 175 | Decoration.replace({ widget: new MentionWidget(id, mention) }).range(from, to), |
| 176 | ), |
| 177 | }), |
| 178 | mentionTheme, |
| 179 | EditorView.clipboardInputFilter.of((text) => hydrateMentions(registry, text)), |
| 180 | EditorView.clipboardOutputFilter.of((text) => serializeMentions(registry, text)), |
| 181 | ]; |
| 182 | } |
| 183 | |
| 184 | /** Insert an atomic mention chip, registering its payload; replaces `range` (the `@query`) when given. */ |
| 185 | export function insertMention( |
| 186 | view: EditorView, |
| 187 | registry: MentionRegistry, |
| 188 | mention: MentionCandidate, |
| 189 | range?: { from: number; to: number }, |
| 190 | ): void { |
| 191 | const id = registry.add(mention); |
| 192 | const sentinel = `⟦mention:${id}⟧`; |
| 193 | const from = range?.from ?? view.state.selection.main.from; |
| 194 | const to = range?.to ?? view.state.selection.main.to; |
| 195 | // Pad a leading space when butting against prior text, and a trailing one so the caret lands clear |
| 196 | // of the atomic chip. |
| 197 | const needsLead = from > 0 && !/\s/.test(view.state.sliceDoc(from - 1, from)); |
| 198 | const insert = `${needsLead ? " " : ""}${sentinel} `; |
| 199 | view.dispatch({ |
| 200 | changes: { from, to, insert }, |
| 201 | selection: { anchor: from + insert.length }, |
| 202 | scrollIntoView: true, |
| 203 | userEvent: "input", |
| 204 | }); |
| 205 | view.focus(); |
| 206 | } |
| 207 | |
| 208 | /** Expand every sentinel to its `@token`, dropping orphans — the text form the consumer receives. */ |
| 209 | export function serializeMentions(registry: MentionRegistry, doc: string): string { |
| 210 | return doc.replace(SENTINEL, (_token, id: string) => { |
| 211 | const mention = registry.get(Number(id)); |
| 212 | return mention ? `@${mention.token}` : ""; |
| 213 | }); |
| 214 | } |
| 215 | |
| 216 | const SAVED_MENTION = /(^|\s)@([^\s@]+)/g; |
| 217 | const FILE_TOKEN = |
| 218 | /^(?:\.?\.?\/|[^/\s]+\/|[^/\s]+\.(?:[cm]?[jt]sx?|json|mdx?|css|scss|html|vue|svelte|py|rs|go|java|kt|swift|rb|php|sql|ya?ml|toml))(?:.*)$/i; |
| 219 | const RANGE_SUFFIX = /:(\d+)(?:-(\d+)|:(\d+))?$/; |
| 220 | |
| 221 | /** Restore canonical draft `@path` tokens to atomic chips. */ |
| 222 | export function hydrateMentions(registry: MentionRegistry, value: string): string { |
| 223 | return value.replace(SAVED_MENTION, (whole, lead: string, savedToken: string) => { |
| 224 | const suffix = /[.,;!?]+$/.exec(savedToken)?.[0] ?? ""; |
| 225 | const token = suffix ? savedToken.slice(0, -suffix.length) : savedToken; |
| 226 | if (/^[a-z][a-z\d+.-]*:\/\//i.test(token)) return whole; |
| 227 | if (!FILE_TOKEN.test(token)) return whole; |
| 228 | const range = RANGE_SUFFIX.exec(token); |
| 229 | const path = range ? token.slice(0, range.index) : token; |
| 230 | const id = registry.add({ |
| 231 | kind: "file", |
| 232 | label: path.slice(path.lastIndexOf("/") + 1), |
| 233 | token, |
| 234 | ...(range |
| 235 | ? { |
| 236 | lineLabel: range[2] |
| 237 | ? `L${range[1]}-${range[2]}` |
| 238 | : range[3] |
| 239 | ? `L${range[1]}:${range[3]}` |
| 240 | : `L${range[1]}`, |
| 241 | } |
| 242 | : {}), |
| 243 | }); |
| 244 | return `${lead}⟦mention:${id}⟧${suffix}`; |
| 245 | }); |
| 246 | } |