@ai-composer/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -0
  3. package/dist/commands/builtins.d.ts +41 -0
  4. package/dist/commands/builtins.d.ts.map +1 -0
  5. package/dist/commands/builtins.js +116 -0
  6. package/dist/commands/builtins.js.map +1 -0
  7. package/dist/commands/registry.d.ts +27 -0
  8. package/dist/commands/registry.d.ts.map +1 -0
  9. package/dist/commands/registry.js +29 -0
  10. package/dist/commands/registry.js.map +1 -0
  11. package/dist/editor.d.ts +185 -0
  12. package/dist/editor.d.ts.map +1 -0
  13. package/dist/editor.js +558 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/errors.d.ts +15 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +22 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/events/event-bus.d.ts +19 -0
  20. package/dist/events/event-bus.d.ts.map +1 -0
  21. package/dist/events/event-bus.js +51 -0
  22. package/dist/events/event-bus.js.map +1 -0
  23. package/dist/events/events.d.ts +94 -0
  24. package/dist/events/events.d.ts.map +1 -0
  25. package/dist/events/events.js +6 -0
  26. package/dist/events/events.js.map +1 -0
  27. package/dist/factory.d.ts +17 -0
  28. package/dist/factory.d.ts.map +1 -0
  29. package/dist/factory.js +18 -0
  30. package/dist/factory.js.map +1 -0
  31. package/dist/history/history.d.ts +48 -0
  32. package/dist/history/history.d.ts.map +1 -0
  33. package/dist/history/history.js +71 -0
  34. package/dist/history/history.js.map +1 -0
  35. package/dist/index.d.ts +43 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +30 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/model/document.d.ts +54 -0
  40. package/dist/model/document.d.ts.map +1 -0
  41. package/dist/model/document.js +177 -0
  42. package/dist/model/document.js.map +1 -0
  43. package/dist/model/nodes.d.ts +80 -0
  44. package/dist/model/nodes.d.ts.map +1 -0
  45. package/dist/model/nodes.js +64 -0
  46. package/dist/model/nodes.js.map +1 -0
  47. package/dist/model/selection.d.ts +40 -0
  48. package/dist/model/selection.d.ts.map +1 -0
  49. package/dist/model/selection.js +79 -0
  50. package/dist/model/selection.js.map +1 -0
  51. package/dist/nodes/registry.d.ts +39 -0
  52. package/dist/nodes/registry.d.ts.map +1 -0
  53. package/dist/nodes/registry.js +93 -0
  54. package/dist/nodes/registry.js.map +1 -0
  55. package/dist/package.json +21 -0
  56. package/dist/plugins/registry.d.ts +53 -0
  57. package/dist/plugins/registry.d.ts.map +1 -0
  58. package/dist/plugins/registry.js +102 -0
  59. package/dist/plugins/registry.js.map +1 -0
  60. package/dist/serialization/builtins.d.ts +30 -0
  61. package/dist/serialization/builtins.d.ts.map +1 -0
  62. package/dist/serialization/builtins.js +102 -0
  63. package/dist/serialization/builtins.js.map +1 -0
  64. package/dist/serialization/index.d.ts +4 -0
  65. package/dist/serialization/index.d.ts.map +1 -0
  66. package/dist/serialization/index.js +3 -0
  67. package/dist/serialization/index.js.map +1 -0
  68. package/dist/serialization/registry.d.ts +17 -0
  69. package/dist/serialization/registry.d.ts.map +1 -0
  70. package/dist/serialization/registry.js +35 -0
  71. package/dist/serialization/registry.js.map +1 -0
  72. package/dist/serialization/serializers.d.ts +12 -0
  73. package/dist/serialization/serializers.d.ts.map +1 -0
  74. package/dist/serialization/serializers.js +3 -0
  75. package/dist/serialization/serializers.js.map +1 -0
  76. package/dist/state/state.d.ts +54 -0
  77. package/dist/state/state.d.ts.map +1 -0
  78. package/dist/state/state.js +6 -0
  79. package/dist/state/state.js.map +1 -0
  80. package/dist/triggers/engine.d.ts +102 -0
  81. package/dist/triggers/engine.d.ts.map +1 -0
  82. package/dist/triggers/engine.js +235 -0
  83. package/dist/triggers/engine.js.map +1 -0
  84. package/dist/types.d.ts +37 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/dist/types.js +3 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/utils/html.d.ts +3 -0
  89. package/dist/utils/html.d.ts.map +1 -0
  90. package/dist/utils/html.js +10 -0
  91. package/dist/utils/html.js.map +1 -0
  92. package/package.json +20 -0
  93. package/project.json +56 -0
  94. package/src/commands/builtins.ts +142 -0
  95. package/src/commands/registry.ts +53 -0
  96. package/src/commands.test.ts +118 -0
  97. package/src/editor.test.ts +311 -0
  98. package/src/editor.ts +826 -0
  99. package/src/errors.ts +25 -0
  100. package/src/events/event-bus.test.ts +62 -0
  101. package/src/events/event-bus.ts +72 -0
  102. package/src/events/events.ts +120 -0
  103. package/src/factory.ts +21 -0
  104. package/src/history/history.ts +108 -0
  105. package/src/index.ts +201 -0
  106. package/src/model/document.test.ts +130 -0
  107. package/src/model/document.ts +207 -0
  108. package/src/model/nodes.ts +151 -0
  109. package/src/model/selection.ts +107 -0
  110. package/src/nodes/registry.ts +142 -0
  111. package/src/plugins/registry.ts +163 -0
  112. package/src/serialization/builtins.ts +123 -0
  113. package/src/serialization/index.ts +20 -0
  114. package/src/serialization/registry.ts +49 -0
  115. package/src/serialization/serializers.ts +15 -0
  116. package/src/state/state.ts +57 -0
  117. package/src/triggers/engine.ts +317 -0
  118. package/src/triggers.test.ts +138 -0
  119. package/src/types.ts +42 -0
  120. package/src/utils/html.ts +9 -0
  121. package/tsconfig.json +15 -0
  122. package/tsconfig.lib.json +15 -0
  123. package/tsconfig.spec.json +16 -0
  124. package/vitest.config.ts +11 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Trigger system — detects trigger characters (`@`, `/`, `#`, `$`, `:` …)
3
+ * typed before the caret, runs the owning trigger's search, and manages the
4
+ * suggestion session in editor state.
5
+ */
6
+
7
+ import type { Unsubscribe } from '../events/event-bus';
8
+ import { AIComposerError, toError } from '../errors';
9
+ import {
10
+ createCommandNode,
11
+ createCustomNode,
12
+ createMentionNode,
13
+ createVariableNode,
14
+ type AIComposerNode,
15
+ } from '../model/nodes';
16
+ import type { AIComposerDocument } from '../model/document';
17
+ import type { SelectionState } from '../model/selection';
18
+ import type { SuggestionItem, TriggerState } from '../state/state';
19
+ import type { AIComposer } from '../editor';
20
+ import type { AIComposerBus } from '../events/events';
21
+
22
+ export interface TriggerSearchContext {
23
+ query: string;
24
+ document: AIComposerDocument;
25
+ selection: SelectionState;
26
+ /** Aborted when a newer keystroke superseded this search. */
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ export interface TriggerSelectContext {
31
+ editor: AIComposer;
32
+ document: AIComposerDocument;
33
+ /** Text node index containing the trigger run. */
34
+ nodeIndex: number;
35
+ /** Offset of the trigger character. */
36
+ triggerOffset: number;
37
+ /** Offset where the query ends. */
38
+ endOffset: number;
39
+ query: string;
40
+ item: SuggestionItem;
41
+ }
42
+
43
+ export interface AIComposerTrigger {
44
+ id: string;
45
+ /** Character that activates the trigger, e.g. '@'. */
46
+ character: string;
47
+ /** Node type produced by the default select behavior ('mention' by default). */
48
+ type?: string;
49
+ /** Allow spaces inside the query (default false). */
50
+ allowSpaces?: boolean;
51
+ /** Allow the trigger character mid-word, e.g. "user@x" (default false). */
52
+ allowMidWord?: boolean;
53
+ /** Provide suggestions for the current query. */
54
+ search(context: TriggerSearchContext): SuggestionItem[] | Promise<SuggestionItem[]>;
55
+ /**
56
+ * Handle selection. When omitted, the trigger run is replaced with an
57
+ * atomic node built from the item (see createNodeFromSuggestion).
58
+ */
59
+ select?(item: SuggestionItem, context: TriggerSelectContext): void;
60
+ }
61
+
62
+ export interface TriggerRegistry {
63
+ register(trigger: AIComposerTrigger): Unsubscribe;
64
+ get(id: string): AIComposerTrigger | undefined;
65
+ list(): AIComposerTrigger[];
66
+ clear(): void;
67
+ }
68
+
69
+ export function createTriggerRegistry(): TriggerRegistry {
70
+ const triggers = new Map<string, AIComposerTrigger>();
71
+ return {
72
+ register(trigger) {
73
+ triggers.set(trigger.id, trigger);
74
+ return () => {
75
+ if (triggers.get(trigger.id) === trigger) triggers.delete(trigger.id);
76
+ };
77
+ },
78
+ get(id) {
79
+ return triggers.get(id);
80
+ },
81
+ list() {
82
+ return [...triggers.values()];
83
+ },
84
+ clear() {
85
+ triggers.clear();
86
+ },
87
+ };
88
+ }
89
+
90
+ /** Build the atomic node a trigger inserts for a chosen suggestion. */
91
+ export function createNodeFromSuggestion(trigger: AIComposerTrigger, item: SuggestionItem): AIComposerNode {
92
+ const type = trigger.type ?? 'mention';
93
+ switch (type) {
94
+ case 'mention':
95
+ return createMentionNode({
96
+ id: item.id,
97
+ label: item.label,
98
+ ...(item.data ? { metadata: item.data } : {}),
99
+ });
100
+ case 'command':
101
+ return createCommandNode({
102
+ id: item.id,
103
+ label: item.label,
104
+ ...(item.data ? { metadata: item.data } : {}),
105
+ });
106
+ case 'variable':
107
+ return createVariableNode({ name: item.label, value: item.data?.value });
108
+ default:
109
+ return createCustomNode({
110
+ plugin: trigger.id,
111
+ nodeType: type,
112
+ data: item.data ?? { id: item.id, label: item.label },
113
+ });
114
+ }
115
+ }
116
+
117
+ /** Narrow host surface the engine needs from the editor (keeps the engine testable). */
118
+ export interface TriggerHost {
119
+ readonly editor: AIComposer;
120
+ readonly document: AIComposerDocument;
121
+ readonly selection: SelectionState;
122
+ readonly triggers: TriggerRegistry;
123
+ readonly events: AIComposerBus;
124
+ patchState(patch: { activeTrigger?: TriggerState | null; suggestions?: SuggestionItem[]; activeSuggestionIndex?: number }): void;
125
+ /** Replace the trigger run with nodes as one history step; caret lands after the run. */
126
+ replaceRun(state: TriggerState, nodes: AIComposerNode[], options?: { trailingSpace?: boolean; source?: 'api' | 'plugin' | 'user' }): void;
127
+ }
128
+
129
+ const WORD_CHAR = /[\p{L}\p{N}_]/u;
130
+
131
+ export class TriggerEngine {
132
+ private active: TriggerState | null = null;
133
+ private items: SuggestionItem[] = [];
134
+ private activeIndex = -1;
135
+ private searchToken = 0;
136
+
137
+ constructor(private readonly host: TriggerHost) {}
138
+
139
+ get activeTrigger(): TriggerState | null {
140
+ return this.active;
141
+ }
142
+
143
+ get suggestions(): SuggestionItem[] {
144
+ return this.items;
145
+ }
146
+
147
+ get activeSuggestionIndex(): number {
148
+ return this.activeIndex;
149
+ }
150
+
151
+ /** Re-evaluate the trigger context after a document change. */
152
+ handleDocumentChange(): void {
153
+ this.evaluate();
154
+ }
155
+
156
+ handleSelectionChange(): void {
157
+ this.evaluate();
158
+ }
159
+
160
+ /** Move the highlighted suggestion (ArrowUp/ArrowDown). */
161
+ moveSelection(delta: number): void {
162
+ if (!this.active || this.items.length === 0) return;
163
+ const next = (this.activeIndex + delta + this.items.length) % this.items.length;
164
+ this.setSuggestions(this.active.triggerId, this.items, next);
165
+ }
166
+
167
+ /** Accept a suggestion (click, Enter/Tab, or programmatic). */
168
+ async accept(item?: SuggestionItem): Promise<void> {
169
+ const state = this.active;
170
+ if (!state) return;
171
+ const trigger = this.host.triggers.get(state.triggerId);
172
+ if (!trigger) {
173
+ this.close('manual');
174
+ return;
175
+ }
176
+ const chosen = item ?? (this.activeIndex >= 0 ? this.items[this.activeIndex] : undefined) ?? this.items[0];
177
+ if (!chosen) return;
178
+
179
+ const context: TriggerSelectContext = {
180
+ editor: this.host.editor,
181
+ document: this.host.document,
182
+ nodeIndex: state.nodeIndex,
183
+ triggerOffset: state.triggerOffset,
184
+ endOffset: state.endOffset,
185
+ query: state.query,
186
+ item: chosen,
187
+ };
188
+
189
+ this.close('select');
190
+ try {
191
+ if (trigger.select) {
192
+ await trigger.select(chosen, context);
193
+ } else {
194
+ this.host.replaceRun(state, [createNodeFromSuggestion(trigger, chosen)], {
195
+ trailingSpace: true,
196
+ source: 'plugin',
197
+ });
198
+ }
199
+ } catch (error) {
200
+ this.host.events.emit('error', { error: toError(error), phase: `trigger:${trigger.id}:select` });
201
+ }
202
+ }
203
+
204
+ /** Close the active trigger session. */
205
+ close(reason: 'select' | 'escape' | 'blur' | 'input' | 'manual' | 'editor-destroy'): void {
206
+ const state = this.active;
207
+ if (!state) return;
208
+ this.active = null;
209
+ this.items = [];
210
+ this.activeIndex = -1;
211
+ this.host.patchState({ activeTrigger: null, suggestions: [], activeSuggestionIndex: -1 });
212
+ this.host.events.emit('triggerClose', { triggerId: state.triggerId, reason });
213
+ }
214
+
215
+ private evaluate(): void {
216
+ const document = this.host.document;
217
+ const selection = this.host.selection;
218
+
219
+ if (selection.anchor.nodeIndex !== selection.focus.nodeIndex ||
220
+ selection.anchor.offset !== selection.focus.offset) {
221
+ this.close('input');
222
+ return;
223
+ }
224
+
225
+ const node = document.nodes[selection.focus.nodeIndex];
226
+ if (!node || node.type !== 'text') {
227
+ this.close('input');
228
+ return;
229
+ }
230
+
231
+ const before = node.text.slice(0, selection.focus.offset);
232
+ let best: { trigger: AIComposerTrigger; state: TriggerState } | null = null;
233
+
234
+ for (const trigger of this.host.triggers.list()) {
235
+ const index = before.lastIndexOf(trigger.character);
236
+ if (index === -1) continue;
237
+ const query = before.slice(index + 1);
238
+ if (query.includes('\n')) continue;
239
+ if (!trigger.allowSpaces && /\s/.test(query)) continue;
240
+ if (!trigger.allowMidWord && index > 0 && WORD_CHAR.test(before[index - 1] ?? '')) continue;
241
+ if (!best || index > best.state.triggerOffset) {
242
+ best = {
243
+ trigger,
244
+ state: {
245
+ triggerId: trigger.id,
246
+ query,
247
+ nodeIndex: selection.focus.nodeIndex,
248
+ triggerOffset: index,
249
+ endOffset: selection.focus.offset,
250
+ },
251
+ };
252
+ }
253
+ }
254
+
255
+ const previous = this.active;
256
+ if (!best) {
257
+ this.close('input');
258
+ return;
259
+ }
260
+
261
+ const changed =
262
+ !previous ||
263
+ previous.triggerId !== best.state.triggerId ||
264
+ previous.query !== best.state.query ||
265
+ previous.nodeIndex !== best.state.nodeIndex ||
266
+ previous.triggerOffset !== best.state.triggerOffset;
267
+
268
+ this.active = best.state;
269
+ if (!previous || previous.triggerId !== best.trigger.id) {
270
+ this.host.events.emit('triggerOpen', { triggerId: best.trigger.id, query: best.state.query });
271
+ }
272
+ this.host.patchState({ activeTrigger: best.state });
273
+ if (changed) {
274
+ this.setSuggestions(best.state.triggerId, [], -1);
275
+ void this.runSearch(best.trigger, best.state);
276
+ }
277
+ }
278
+
279
+ private async runSearch(trigger: AIComposerTrigger, state: TriggerState): Promise<void> {
280
+ const token = ++this.searchToken;
281
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
282
+ try {
283
+ const results = await trigger.search({
284
+ query: state.query,
285
+ document: this.host.document,
286
+ selection: this.host.selection,
287
+ signal: controller?.signal,
288
+ });
289
+ if (token !== this.searchToken || this.active !== state) return;
290
+ const items = (results ?? []).filter((item) => item && typeof item.id === 'string');
291
+ if (items.length === 0) {
292
+ this.setSuggestions(state.triggerId, [], -1);
293
+ return;
294
+ }
295
+ this.setSuggestions(state.triggerId, items, 0);
296
+ } catch (error) {
297
+ if (token === this.searchToken) {
298
+ this.host.events.emit('error', { error: toError(error), phase: `trigger:${trigger.id}:search` });
299
+ }
300
+ }
301
+ }
302
+
303
+ private setSuggestions(triggerId: string, items: SuggestionItem[], activeIndex: number): void {
304
+ this.items = items;
305
+ this.activeIndex = activeIndex;
306
+ this.host.patchState({ suggestions: items, activeSuggestionIndex: activeIndex });
307
+ this.host.events.emit('suggestionsChange', { triggerId: items.length ? triggerId : null, suggestions: items, activeIndex });
308
+ }
309
+ }
310
+
311
+ /** Convenience factory used by tests and plugins to build triggers quickly. */
312
+ export function defineTrigger(trigger: AIComposerTrigger): AIComposerTrigger {
313
+ if (!trigger.character || trigger.character.length !== 1) {
314
+ throw new AIComposerError('UNKNOWN_TRIGGER', `Trigger "${trigger.id}" must define a single character`);
315
+ }
316
+ return trigger;
317
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createAIComposer } from './factory';
3
+ import { createPosition, createSelection } from './model/selection';
4
+ import type { SuggestionItem } from './state/state';
5
+
6
+ const people: SuggestionItem[] = [
7
+ { id: 'u1', label: 'Ada Lovelace' },
8
+ { id: 'u2', label: 'Alan Turing' },
9
+ { id: 'u3', label: 'Grace Hopper' },
10
+ ];
11
+
12
+ function editorWithMention() {
13
+ const search = vi.fn(async (ctx: { query: string }) =>
14
+ people.filter((p) => p.label.toLowerCase().includes(ctx.query.toLowerCase())),
15
+ );
16
+ const editor = createAIComposer({
17
+ plugins: [
18
+ {
19
+ name: 'mention',
20
+ triggers: [{ id: 'mention', character: '@', type: 'mention', search }],
21
+ },
22
+ ],
23
+ });
24
+ return { editor, search };
25
+ }
26
+
27
+ function type(editor: ReturnType<typeof createAIComposer>, text: string): void {
28
+ const current = editor.serialize('text') as string;
29
+ editor.applyViewUpdate({ nodes: [{ type: 'text', key: 't', text: current + text }] });
30
+ editor.setSelection(createSelection(createPosition(0, (current + text).length)));
31
+ }
32
+
33
+ describe('trigger engine', () => {
34
+ it('opens a trigger session when @ is typed and yields suggestions', async () => {
35
+ const { editor } = editorWithMention();
36
+ const onOpen = vi.fn();
37
+ const onSuggestions = vi.fn();
38
+ editor.on('triggerOpen', onOpen);
39
+ editor.on('suggestionsChange', onSuggestions);
40
+
41
+ type(editor, 'Hey @ad');
42
+ await vi.waitFor(() => expect(editor.getState().suggestions.length).toBeGreaterThan(0));
43
+
44
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ triggerId: 'mention', query: 'ad' }));
45
+ const state = editor.getState();
46
+ expect(state.activeTrigger?.query).toBe('ad');
47
+ expect(state.suggestions.map((s) => s.label)).toEqual(['Ada Lovelace']);
48
+ expect(state.activeSuggestionIndex).toBe(0);
49
+ expect(onSuggestions).toHaveBeenCalled();
50
+ });
51
+
52
+ it('does not open the trigger mid-word', async () => {
53
+ const { editor } = editorWithMention();
54
+ type(editor, 'user@example');
55
+ await Promise.resolve();
56
+ expect(editor.getState().activeTrigger).toBeNull();
57
+ });
58
+
59
+ it('closes when a space breaks the query', async () => {
60
+ const { editor } = editorWithMention();
61
+ const onClose = vi.fn();
62
+ editor.on('triggerClose', onClose);
63
+
64
+ type(editor, '@jo');
65
+ await vi.waitFor(() => expect(editor.getState().activeTrigger).not.toBeNull());
66
+ type(editor, 'hn smith');
67
+ expect(editor.getState().activeTrigger).toBeNull();
68
+ expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ reason: 'input' }));
69
+ });
70
+
71
+ it('accepting a suggestion replaces the trigger run with a node', async () => {
72
+ const { editor } = editorWithMention();
73
+ type(editor, 'Ping @gr');
74
+ await vi.waitFor(() => expect(editor.getState().suggestions.length).toBe(1));
75
+
76
+ await editor.acceptSuggestion();
77
+
78
+ expect(editor.serialize('text')).toBe('Ping @Grace Hopper ');
79
+ expect(editor.getState().activeTrigger).toBeNull();
80
+ expect(editor.getValue().nodes.some((n) => n.type === 'mention')).toBe(true);
81
+ // caret sits after the inserted chip + trailing space
82
+ expect(editor.serialize('text')).toContain('Ping @Grace Hopper ');
83
+ });
84
+
85
+ it('moves the highlighted suggestion', async () => {
86
+ const editor = createAIComposer({
87
+ plugins: [
88
+ {
89
+ name: 'mention',
90
+ triggers: [
91
+ { id: 'mention', character: '@', search: () => people },
92
+ ],
93
+ },
94
+ ],
95
+ });
96
+ type(editor, '@');
97
+ await vi.waitFor(() => expect(editor.getState().suggestions.length).toBe(3));
98
+
99
+ editor.moveSuggestionSelection(1);
100
+ expect(editor.getState().activeSuggestionIndex).toBe(1);
101
+ editor.moveSuggestionSelection(1);
102
+ expect(editor.getState().activeSuggestionIndex).toBe(2);
103
+ editor.moveSuggestionSelection(1);
104
+ expect(editor.getState().activeSuggestionIndex).toBe(0); // wraps
105
+
106
+ await editor.acceptSuggestion();
107
+ expect(editor.serialize('text')).toBe('@Ada Lovelace ');
108
+ });
109
+
110
+ it('openTrigger inserts the character programmatically', async () => {
111
+ const { editor } = editorWithMention();
112
+ editor.setValue('hello ');
113
+ editor.openTrigger('mention');
114
+ await vi.waitFor(() => expect(editor.getState().activeTrigger?.query).toBe(''));
115
+ expect(editor.serialize('text')).toBe('hello @');
116
+ });
117
+
118
+ it('custom select handlers control the inserted nodes', async () => {
119
+ const select = vi.fn();
120
+ const editor = createAIComposer({
121
+ plugins: [
122
+ {
123
+ name: 'mention',
124
+ triggers: [
125
+ { id: 'mention', character: '@', search: () => people, select },
126
+ ],
127
+ },
128
+ ],
129
+ });
130
+ type(editor, '@a');
131
+ await vi.waitFor(() => expect(editor.getState().suggestions.length).toBeGreaterThan(0));
132
+ await editor.acceptSuggestion();
133
+ expect(select).toHaveBeenCalledWith(
134
+ expect.objectContaining({ id: 'u1' }),
135
+ expect.objectContaining({ query: 'a' }),
136
+ );
137
+ });
138
+ });
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** Editor configuration types. */
2
+
3
+ import type { AIComposerDocument } from './model/document';
4
+ import type { AIComposerNode } from './model/nodes';
5
+ import type { AIComposerPlugin } from './plugins/registry';
6
+ import type { Serializer } from './serialization/serializers';
7
+ import type { NodeDefinition } from './nodes/registry';
8
+ import type { AIComposer } from './editor';
9
+ import type { HistoryOptions } from './history/history';
10
+
11
+ export type SubmitKey = 'enter' | 'shift-enter' | 'none';
12
+
13
+ export interface SubmitConfig {
14
+ /** Async handler invoked by editor.submit(). */
15
+ onSubmit?(value: AIComposerDocument, editor: AIComposer): void | Promise<void>;
16
+ /** Clear the document after a successful submit (default false). */
17
+ clearOnSubmit?: boolean;
18
+ /** Allow submitting an empty document (default false). */
19
+ allowEmpty?: boolean;
20
+ }
21
+
22
+ export interface AIComposerConfig {
23
+ /** Presentation mode / preset name — 'default' | 'compact' | 'chat' | 'expanded' | custom. */
24
+ mode?: string;
25
+ placeholder?: string;
26
+ disabled?: boolean;
27
+ readonly?: boolean;
28
+ submit?: SubmitConfig;
29
+ submitKey?: SubmitKey;
30
+ history?: HistoryOptions;
31
+ }
32
+
33
+ export interface AIComposerOptions extends AIComposerConfig {
34
+ /** Initial value: document, node list or plain string. */
35
+ value?: AIComposerDocument | AIComposerNode[] | string;
36
+ plugins?: AIComposerPlugin[];
37
+ /** Extra/overriding node type definitions. */
38
+ nodeTypes?: NodeDefinition[];
39
+ /** Extra/overriding serializers. */
40
+ serializers?: Serializer[];
41
+ }
42
+
@@ -0,0 +1,9 @@
1
+ /** Escape a string for safe interpolation into HTML text/attribute contexts. */
2
+ export function escapeHtml(value: string): string {
3
+ return value
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": []
5
+ },
6
+ "include": [
7
+ "src/**/*.ts",
8
+ "src/**/*.tsx",
9
+ "vitest.config.ts"
10
+ ],
11
+ "exclude": [
12
+ "node_modules",
13
+ "dist"
14
+ ]
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "stripInternal": true,
7
+ "outDir": "../../dist/packages/core"
8
+ },
9
+ "exclude": [
10
+ "**/*.test.ts",
11
+ "**/*.test.tsx",
12
+ "vitest.config.ts",
13
+ "src/test-setup.ts"
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": [
6
+ "vitest"
7
+ ]
8
+ },
9
+ "include": [
10
+ "src/**/*.ts",
11
+ "src/**/*.tsx"
12
+ ],
13
+ "exclude": [
14
+ "vitest.config.ts"
15
+ ]
16
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ resolve: {
4
+ },
5
+ test: {
6
+ name: 'core',
7
+ environment: 'node',
8
+ include: ['src/**/*.test.{ts,tsx}'],
9
+ },
10
+ });
11
+