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.
6import type { Extension } from "@codemirror/state";
7import { Decoration, EditorView, WidgetType } from "@codemirror/view";
8
9import { chipField } from "./chips.ts";
10
11// Kept as two statements: kiln's type-stripping rejects inline `type` specifiers in re-exports.
12export { mentionTriggerAt } from "./triggers.ts";
13export type { MentionTrigger } from "./triggers.ts";
14
15/** A mention target surfaced by a provider: a workspace file or a code symbol. */
16export 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 */
35export 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. */
40export 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
52const 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 */
59export interface MentionRegistry {
60 add(mention: MentionCandidate): number;
61 get(id: number): MentionCandidate | undefined;
62}
63
64export 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
77const 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>';
79const 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)).
84class 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. */
127export 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 */
150const 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
169export 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. */
185export 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. */
209export 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
216const SAVED_MENTION = /(^|\s)@([^\s@]+)/g;
217const 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;
219const RANGE_SUFFIX = /:(\d+)(?:-(\d+)|:(\d+))?$/;
220
221/** Restore canonical draft `@path` tokens to atomic chips. */
222export 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}