1import {
2 chipField,
3 createMarkdownEditor,
4 enclosingCodeBlock,
5 insertNewline,
6 livePreview,
7 type MarkdownEditor,
8} from "@clo/markdown-editor";
9import { isolateHistory, indentLess, indentMore } from "@codemirror/commands";
10import {
11 defaultHighlightStyle,
12 HighlightStyle,
13 syntaxHighlighting,
14 syntaxTree,
15} from "@codemirror/language";
16import {
17 Compartment,
18 EditorState,
19 Prec,
20 StateEffect,
21 StateField,
22 Transaction,
23} from "@codemirror/state";
24import type { SyntaxNode } from "@lezer/common";
25import { Decoration, EditorView, WidgetType } from "@codemirror/view";
26
27import { cn } from "~/lib/utils";
28import { collectComposerPromptInlineTokens } from "~/composer-editor-mentions";
29import { collapseExpandedComposerCursor, expandCollapsedComposerCursor } from "~/composer-logic";
30import { getTimelinePageScrollKey } from "./chat/pageScrollController";
31import { importPastedComposerText } from "./composerInlineTokenPaste";
32import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
33import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
34
35export type ComposerToken = ReturnType<typeof collectComposerPromptInlineTokens>[number];
36export type ComposerChip = { dom: HTMLElement } & (
37 | { kind: "chip"; token: ComposerToken }
38 | { kind: "fence"; block: ReturnType<typeof composerCodeBlocks>[number] }
39);
40
41// Live-preview decorations own prose colors; fenced code uses the Shiki extension.
42const composerSyntaxHighlight = syntaxHighlighting(
43 HighlightStyle.define(
44 defaultHighlightStyle.specs.map((style) => ({ ...style, color: "inherit" })),
45 ),
46);
47
48export function composerTokens(state: EditorState, literalText: boolean): ComposerToken[] {
49 if (literalText) return [];
50 const code: Array<{ from: number; to: number }> = [];
51 syntaxTree(state).iterate({
52 enter(node) {
53 if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) {
54 code.push({ from: node.from, to: node.to });
55 return false;
56 }
57 return undefined;
58 },
59 });
60 return collectComposerPromptInlineTokens(state.doc.toString()).filter(
61 (token) => !code.some((range) => token.start < range.to && token.end > range.from),
62 );
63}
64
65export function composerSuggestionsSuppressed(view: EditorView, literalText: boolean): boolean {
66 const range = view.state.selection.main;
67 if (literalText || view.compositionStarted || !range.empty) return true;
68 for (
69 let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(range.head, -1);
70 node;
71 node = node.parent
72 ) {
73 if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) return true;
74 }
75 return composerTokens(view.state, literalText).some(
76 (token) => token.start <= range.head && token.end >= range.head,
77 );
78}
79
80class ComposerChipWidget extends WidgetType {
81 constructor(
82 readonly token: ComposerToken,
83 readonly mount: (chip: ComposerChip) => void,
84 readonly unmount: (dom: HTMLElement) => void,
85 ) {
86 super();
87 }
88 override eq(other: ComposerChipWidget) {
89 return (
90 this.token.start === other.token.start &&
91 this.token.end === other.token.end &&
92 this.token.source === other.token.source
93 );
94 }
95 override toDOM() {
96 const dom = document.createElement("span");
97 dom.contentEditable = "false";
98 dom.className = "relative inline-flex select-none items-center align-middle leading-none";
99 dom.dataset.composerChip = "true";
100 if (this.token.type === "citation") dom.dataset.citationStart = String(this.token.start);
101 dom.setAttribute(
102 "aria-label",
103 this.token.type === "mention"
104 ? this.token.value
105 : this.token.type === "skill"
106 ? this.token.value
107 : this.token.type === "context-reference"
108 ? this.token.label
109 : "Assistant citation",
110 );
111 this.mount({ dom, kind: "chip", token: this.token });
112 return dom;
113 }
114 override destroy(dom: HTMLElement) {
115 this.unmount(dom);
116 }
117}
118
119class ComposerFenceWidget extends WidgetType {
120 constructor(
121 readonly block: ReturnType<typeof composerCodeBlocks>[number],
122 readonly mount: (chip: ComposerChip) => void,
123 readonly unmount: (dom: HTMLElement) => void,
124 ) {
125 super();
126 }
127 override eq(other: ComposerFenceWidget) {
128 return (
129 this.block.from === other.block.from &&
130 this.block.info === other.block.info &&
131 this.block.codeTo === other.block.codeTo
132 );
133 }
134 override toDOM() {
135 const dom = document.createElement("span");
136 dom.contentEditable = "false";
137 dom.className = "ml-3 inline-flex items-center align-middle font-sans";
138 dom.dataset.composerFence = "true";
139 this.mount({ dom, kind: "fence", block: this.block });
140 return dom;
141 }
142 override destroy(dom: HTMLElement) {
143 this.unmount(dom);
144 }
145}
146
147export function createComposerMarkdownEditor(options: {
148 props: () => ComposerPromptEditorProps;
149 mountChip: (chip: ComposerChip) => void;
150 unmountChip: (dom: HTMLElement) => void;
151}): MarkdownEditor & {
152 configure: () => void;
153 snapshot: () => { value: string; cursor: number; expandedCursor: number; contextIds: string[] };
154} {
155 const presentation = new Compartment();
156 const attributes = new Compartment();
157 const editing = new Compartment();
158 const configuredLiteral = StateEffect.define<boolean>();
159 const literal = StateField.define<boolean>({
160 create: () => options.props().literalText ?? false,
161 update(value, transaction) {
162 for (const effect of transaction.effects)
163 if (effect.is(configuredLiteral)) return effect.value;
164 return value;
165 },
166 });
167 const snapshot = () => {
168 const view = editor.view;
169 const value = view.state.doc.toString();
170 const expandedCursor = view.state.selection.main.head;
171 const literalText = view.state.field(literal);
172 return {
173 value,
174 cursor: collapseExpandedComposerCursor(value, expandedCursor, literalText),
175 expandedCursor,
176 contextIds: composerTokens(view.state, literalText).flatMap((token) =>
177 token.type === "context-reference" ? [token.contextId] : [],
178 ),
179 };
180 };
181 const report = () => {
182 const props = options.props();
183 const current = snapshot();
184 props.onChange(
185 current.value,
186 current.cursor,
187 current.expandedCursor,
188 composerSuggestionsSuppressed(editor.view, props.literalText ?? false),
189 current.contextIds,
190 );
191 };
192 const editor = createMarkdownEditor({
193 preset: "composer",
194 mentions: false,
195 preview: false,
196 doc: options.props().value,
197 extensions: [
198 literal,
199 presentation.of([]),
200 attributes.of([]),
201 editing.of([]),
202 chipField({
203 atomic: true,
204 onSelection: true,
205 rebuild: (transaction) =>
206 syntaxTree(transaction.startState) !== syntaxTree(transaction.state),
207 chips: (state) =>
208 composerTokens(state, state.field(literal)).map((token) =>
209 Decoration.replace({
210 widget: new ComposerChipWidget(token, options.mountChip, options.unmountChip),
211 }).range(token.start, token.end),
212 ),
213 }),
214 chipField({
215 onSelection: true,
216 rebuild: (transaction) =>
217 syntaxTree(transaction.startState) !== syntaxTree(transaction.state),
218 chips: (state) =>
219 options.props().richTextEnabled && !state.field(literal)
220 ? composerCodeBlocks(state).map((block) =>
221 Decoration.widget({
222 widget: new ComposerFenceWidget(block, options.mountChip, options.unmountChip),
223 side: 1,
224 }).range(state.doc.lineAt(block.from).to),
225 )
226 : [],
227 }),
228 Prec.highest(
229 EditorView.domEventHandlers({
230 keydown(event, view) {
231 const props = options.props();
232 if (props.disabled) return false;
233 const pageKey = getTimelinePageScrollKey({
234 altKey: event.altKey,
235 ctrlKey: event.ctrlKey,
236 metaKey: event.metaKey,
237 shiftKey: event.shiftKey,
238 defaultPrevented: event.defaultPrevented,
239 isComposing: event.isComposing || view.compositionStarted,
240 keyCode: event.keyCode,
241 key: event.key,
242 clientHeight: view.scrollDOM.clientHeight,
243 scrollHeight: view.scrollDOM.scrollHeight,
244 scrollTop: view.scrollDOM.scrollTop,
245 });
246 if (pageKey && props.onPageScrollKeyDown) {
247 props.onPageScrollKeyDown(pageKey);
248 event.preventDefault();
249 event.stopPropagation();
250 return true;
251 }
252 if (event.isComposing || event.keyCode === 229 || view.compositionStarted) {
253 if (event.key === "Enter") {
254 event.preventDefault();
255 return true;
256 }
257 return false;
258 }
259 const range = view.state.selection.main;
260 if (
261 event.key === "Tab" &&
262 event.shiftKey &&
263 !event.altKey &&
264 !event.metaKey &&
265 !event.ctrlKey &&
266 range.empty &&
267 !props.literalText
268 ) {
269 const citation = composerTokens(view.state, false).find(
270 (token) => token.type === "citation" && token.end === range.head,
271 );
272 const button = citation
273 ? view.dom.querySelector<HTMLElement>(
274 `[data-citation-start="${citation.start}"] [data-citation-comment-trigger]`,
275 )
276 : null;
277 if (button) {
278 event.preventDefault();
279 event.stopPropagation();
280 button.focus();
281 return true;
282 }
283 }
284 const block = enclosingCodeBlock(view.state, range.head);
285 if (block && !props.literalText && !event.metaKey && !event.ctrlKey) {
286 if (event.key === "Enter") return false;
287 if (event.key === "Tab") {
288 const handled = (event.shiftKey ? indentLess : indentMore)(view);
289 if (handled) {
290 event.preventDefault();
291 event.stopPropagation();
292 }
293 return handled;
294 }
295 }
296 const handled = props.onCommandKeyDown?.(event.key, event) ?? false;
297 if (handled) {
298 event.preventDefault();
299 event.stopPropagation();
300 return true;
301 }
302 if (props.literalText) {
303 if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) {
304 insertNewline(view);
305 event.preventDefault();
306 return true;
307 }
308 if (
309 (event.metaKey || event.ctrlKey) &&
310 ["b", "i"].includes(event.key.toLowerCase())
311 ) {
312 event.preventDefault();
313 return true;
314 }
315 if (["Tab", "*", "`"].includes(event.key)) return true;
316 }
317 return false;
318 },
319 compositionstart() {
320 const props = options.props();
321 const current = snapshot();
322 props.onChange(
323 current.value,
324 current.cursor,
325 current.expandedCursor,
326 true,
327 current.contextIds,
328 );
329 return false;
330 },
331 compositionend(_event, view) {
332 queueMicrotask(() => {
333 if (view.dom.isConnected) report();
334 });
335 return false;
336 },
337 paste(event, view) {
338 const props = options.props();
339 if (props.disabled) return true;
340 const data = event.clipboardData;
341 if (!data || event.defaultPrevented) return false;
342 const text = props.literalText
343 ? data.getData("text/plain")
344 : importPastedComposerText(data, props.importContextFragment);
345 if (!text) return false;
346 event.preventDefault();
347 view.dispatch({
348 ...view.state.replaceSelection(text),
349 annotations: isolateHistory.of("full"),
350 userEvent: "input.paste",
351 scrollIntoView: true,
352 });
353 return true;
354 },
355 }),
356 ),
357 EditorView.updateListener.of((update) => {
358 if (update.docChanged || update.selectionSet) {
359 if (
360 update.transactions.some(
361 (transaction) => transaction.annotation(Transaction.userEvent) === "t3.controlled",
362 )
363 )
364 return;
365 if (!update.state.selection.main.empty) options.props().onVisibleSelectionChange?.();
366 report();
367 }
368 }),
369 Prec.highest(
370 EditorView.theme({
371 "&": {
372 color: "var(--contrast-foreground)",
373 fontSize: "inherit",
374 "--markdown-text": "var(--contrast-foreground)",
375 "--markdown-muted": "var(--contrast-muted-foreground)",
376 "--markdown-panel": "var(--muted)",
377 "--markdown-accent": "var(--info-foreground)",
378 },
379 ".cm-content": {
380 padding: "0",
381 caretColor: "var(--contrast-foreground)",
382 fontFamily: "inherit",
383 minHeight: "inherit",
384 },
385 ".cm-scroller": {
386 lineHeight: "inherit",
387 maxHeight: "inherit",
388 minHeight: "inherit",
389 overflowY: "auto",
390 overflowX: "hidden",
391 fontFamily: "inherit",
392 },
393 "&.cm-focused": { outline: "none" },
394 ".cm-lp-code, .cm-lp-codeblock": {
395 fontFamily: "var(--font-mono)",
396 fontSize: "var(--font-size-code, 13px)",
397 },
398 }),
399 ),
400 ],
401 });
402 const configure = () => {
403 const props = options.props();
404 const literalText = props.literalText ?? false;
405 const configuredPreview = presentation.get(editor.view.state);
406 const previewEnabled = Array.isArray(configuredPreview) && configuredPreview.length > 0;
407 const showPreview = Boolean(props.richTextEnabled && !literalText);
408 editor.view.dispatch({
409 selection: editor.view.state.selection,
410 annotations: Transaction.userEvent.of("t3.controlled"),
411 effects: [
412 configuredLiteral.of(literalText),
413 editing.reconfigure([
414 EditorView.editable.of(!props.disabled),
415 EditorState.readOnly.of(props.disabled),
416 EditorState.transactionFilter.of((transaction) =>
417 props.disabled &&
418 transaction.docChanged &&
419 transaction.annotation(Transaction.userEvent) !== "t3.controlled"
420 ? []
421 : transaction,
422 ),
423 ]),
424 attributes.reconfigure([
425 EditorView.editorAttributes.of({
426 class: cn(
427 "-m-1 block max-h-52 min-h-19.5 p-1 bg-transparent leading-relaxed text-foreground",
428 props.className,
429 ),
430 }),
431 EditorView.contentAttributes.of({
432 "data-testid": "composer-editor",
433 "data-composer-rich-text": props.richTextEnabled && !literalText ? "true" : "false",
434 role: "textbox",
435 "aria-multiline": "true",
436 "aria-label": props.ariaLabel ?? "Message",
437 "aria-readonly": String(props.disabled),
438 "aria-placeholder": props.placeholder,
439 ...(props.suggestionListId && !props.disabled
440 ? { "aria-autocomplete": "list", "aria-haspopup": "listbox" }
441 : {}),
442 ...(props.activeSuggestionId && props.suggestionListId
443 ? {
444 "aria-controls": props.suggestionListId,
445 "aria-activedescendant": props.activeSuggestionId,
446 }
447 : {}),
448 }),
449 ]),
450 ...(showPreview !== previewEnabled
451 ? [
452 presentation.reconfigure(
453 showPreview
454 ? [
455 composerSyntaxHighlight,
456 composerCodeBlockHighlight,
457 livePreview({
458 blockWidgets: false,
459 html: false,
460 exclude: (state) =>
461 composerTokens(state, false).map((token) => ({
462 from: token.start,
463 to: token.end,
464 })),
465 }),
466 ]
467 : [],
468 ),
469 ]
470 : []),
471 ],
472 });
473 };
474 configure();
475 editor.view.dispatch({
476 selection: {
477 anchor: expandCollapsedComposerCursor(
478 options.props().value,
479 options.props().cursor,
480 options.props().literalText,
481 ),
482 },
483 annotations: Transaction.userEvent.of("t3.controlled"),
484 });
485 return { ...editor, configure, snapshot };
486}