@aleph-alpha/chat-kit 6.3.0 → 6.4.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 (47) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-gV-lp0ZB.js → CkHistory.vue_vue_type_script_setup_true_lang-B0WUzTHa.js} +11 -5
  2. package/dist/chat-kit.css +5 -5
  3. package/dist/{code-palette-DAJUTnR-.js → code-palette-DH_G7mZ5.js} +111 -110
  4. package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts +6 -0
  5. package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts.map +1 -1
  6. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +1 -0
  7. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts.map +1 -1
  8. package/dist/components/base/CkCodeEditor/badgeDecoration.d.ts +47 -0
  9. package/dist/components/base/CkCodeEditor/badgeDecoration.d.ts.map +1 -0
  10. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts +4 -0
  11. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts.map +1 -1
  12. package/dist/components/base/CkCodeEditor/types.d.ts +20 -0
  13. package/dist/components/base/CkCodeEditor/types.d.ts.map +1 -1
  14. package/dist/components/index.js +1 -1
  15. package/dist/{createCodeMirror-BkV6FEPn.js → createCodeMirror-5iswYetS.js} +70 -2
  16. package/dist/{createCrepeEditor-Ci5FHHIV.js → createCrepeEditor-C-AQ1w_e.js} +2 -2
  17. package/dist/{index-Cxs6kPho.js → index-ABUMl7H3.js} +3 -3
  18. package/dist/{index-DufAP1JF.js → index-BlIc9r0r.js} +3 -3
  19. package/dist/{index-DxTzaHLr.js → index-BqWO6mvz.js} +4 -4
  20. package/dist/{index-DhwsowEf.js → index-CFGO7LRz.js} +2 -2
  21. package/dist/{index-D6ftdiLa.js → index-CG8e3146.js} +1 -1
  22. package/dist/{index-Dl24CCB3.js → index-CIF4qQzR.js} +4 -4
  23. package/dist/{index-DgCcFC3N.js → index-CR2kGrpY.js} +3 -3
  24. package/dist/{index-BoUJGvvQ.js → index-CWp_t8Wl.js} +2 -2
  25. package/dist/{index-BUCqcTPA.js → index-D0IZW-Up.js} +2 -2
  26. package/dist/{index-CYk7AB8M.js → index-DKwEe_Tu.js} +4 -4
  27. package/dist/{index-w2yzH7vs.js → index-DLjsqQHx.js} +3 -3
  28. package/dist/{index-DqGDenO9.js → index-DPgw0Cf_.js} +2 -2
  29. package/dist/{index-CB6xZVsf.js → index-Db5-PJG-.js} +2 -2
  30. package/dist/{index-MfXW4ZyN.js → index-Dbi75pKH.js} +3 -3
  31. package/dist/{index-BhvbqYsK.js → index-DfLCrnVq.js} +2 -2
  32. package/dist/{index-ZHg2R9iD.js → index-GpSpJ3ti.js} +3 -3
  33. package/dist/{index-CN8d3yl1.js → index-Mh3WUU-6.js} +1 -1
  34. package/dist/{index-BLVAx_f8.js → index-QAs789O5.js} +2 -2
  35. package/dist/{index-B2tew8uf.js → index-S4ylc8JK.js} +3 -3
  36. package/dist/{index-CIF93PZF.js → index-XZeturb3.js} +3 -3
  37. package/dist/{index-BtY1uxn6.js → index-g2X42srv.js} +3 -3
  38. package/dist/{index-gYt3E3Cp.js → index-hEJ_Bavx.js} +2 -2
  39. package/dist/{index-BvG6l44n.js → index-hbS5PDHj.js} +3 -3
  40. package/dist/index.js +1 -1
  41. package/package.json +3 -3
  42. package/src/components/base/CkCodeEditor/CkCodeEditor.spec.ts +61 -0
  43. package/src/components/base/CkCodeEditor/CkCodeEditor.stories.ts +94 -0
  44. package/src/components/base/CkCodeEditor/CkCodeEditor.vue +8 -2
  45. package/src/components/base/CkCodeEditor/badgeDecoration.ts +173 -0
  46. package/src/components/base/CkCodeEditor/createCodeMirror.ts +9 -0
  47. package/src/components/base/CkCodeEditor/types.ts +26 -0
@@ -4,6 +4,32 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite';
4
4
  import CkCodeEditor from './CkCodeEditor.vue';
5
5
  import CkMarkdownRenderer from '../CkMarkdownRenderer/CkMarkdownRenderer.vue';
6
6
 
7
+ const folderIcon =
8
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
9
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
10
+ 'stroke-linejoin="round" width="12" height="12" aria-hidden="true">' +
11
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
12
+
13
+ /** Matches `@collection:<id>` and draws each one as a folder pill. */
14
+ const collectionBadge = {
15
+ pattern: /@collection:([\w-]+)/g,
16
+ render: (match: RegExpExecArray) => ({
17
+ label: (match[1] ?? '').replace(/-/g, ' '),
18
+ icon: folderIcon,
19
+ title: match[0],
20
+ }),
21
+ };
22
+
23
+ /** Matches `{{ name }}`, showing that the trigger is whatever you pattern for. */
24
+ const handlebarsBadge = {
25
+ pattern: /\{\{\s*([\w-]+)\s*\}\}/g,
26
+ render: (match: RegExpExecArray) => ({
27
+ label: match[1] ?? '',
28
+ icon: folderIcon,
29
+ title: match[0],
30
+ }),
31
+ };
32
+
7
33
  const meta: Meta<typeof CkCodeEditor> = {
8
34
  title: 'Base/CkCodeEditor',
9
35
  component: CkCodeEditor,
@@ -31,6 +57,20 @@ const meta: Meta<typeof CkCodeEditor> = {
31
57
  showLineNumbers: { control: 'boolean' },
32
58
  lineWrapping: { control: 'boolean' },
33
59
  autofocus: { control: 'boolean' },
60
+ // A regex plus a callback cannot be typed into a control, so the rule is
61
+ // offered as named presets instead. Without this, badges could only be seen
62
+ // on the one story that hard-codes a rule.
63
+ badge: {
64
+ options: ['none', 'collections', 'handlebars'],
65
+ mapping: {
66
+ none: undefined,
67
+ collections: collectionBadge,
68
+ handlebars: handlebarsBadge,
69
+ },
70
+ control: { type: 'radio' },
71
+ description:
72
+ 'Pattern plus a render callback turning matched text into inline pills. Pick a preset to try it on any story.',
73
+ },
34
74
  writeToClipboard: {
35
75
  control: false,
36
76
  description:
@@ -69,6 +109,14 @@ const pythonSample = [
69
109
  ' return f"{opener}, {self.name}!"',
70
110
  ].join('\n');
71
111
 
112
+ const badgeSample = [
113
+ 'You improve and draft work writing: emails, project summaries and status',
114
+ 'updates, tightening structure and matching register to the audience.',
115
+ '',
116
+ 'For analyses, use @collection:collection-name-3 and @collection:collection-name-4',
117
+ 'and distinguish an opinion from a fact.',
118
+ ].join('\n');
119
+
72
120
  const defaultTemplateSource = `<script setup lang="ts">
73
121
  import { ref } from 'vue';
74
122
  import { CkCodeEditor } from '@aleph-alpha/chat-kit';
@@ -149,6 +197,29 @@ const code = ref(longFile);
149
197
  <CkCodeEditor v-model="code" language="typescript" max-height="16rem" line-wrapping />
150
198
  </template>`;
151
199
 
200
+ const withBadgesTemplateSource = `<script setup lang="ts">
201
+ import { ref } from 'vue';
202
+ import { CkCodeEditor } from '@aleph-alpha/chat-kit';
203
+
204
+ const prompt = ref('For analyses, use @collection:sales and @collection:support.');
205
+
206
+ // The document keeps the raw text; only the drawing is replaced.
207
+ const badge = {
208
+ pattern: /@collection:([\\w-]+)/g,
209
+ render: (m) => ({ label: m[1], icon: folderIcon, title: m[0] }),
210
+ };
211
+ </script>
212
+
213
+ <template>
214
+ <CkCodeEditor
215
+ v-model="prompt"
216
+ language="markdown"
217
+ :badge="badge"
218
+ :line-wrapping="true"
219
+ aria-label="Prompt"
220
+ />
221
+ </template>`;
222
+
152
223
  const matchesRendererTemplateSource = `<script setup lang="ts">
153
224
  import { ref } from 'vue';
154
225
  import { CkCodeEditor, CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
@@ -279,6 +350,29 @@ export const ScrollingAndWrapping: Story = {
279
350
  parameters: { docs: { source: { code: scrollingTemplateSource } } },
280
351
  };
281
352
 
353
+ /**
354
+ * `badge` draws matched references as inline pills, the way a mention renders in
355
+ * prose. The document still holds the raw `@collection:…` text, so `getCode()`
356
+ * and copy give the real reference; a pill deletes as one unit.
357
+ */
358
+ export const WithBadges: Story = {
359
+ render: (args) => ({
360
+ components: { CkCodeEditor },
361
+ setup() {
362
+ const code = ref(args.modelValue ?? '');
363
+ return { args, code };
364
+ },
365
+ template: `<CkCodeEditor v-bind="args" v-model="code" aria-label="Prompt" />`,
366
+ }),
367
+ args: {
368
+ modelValue: badgeSample,
369
+ language: 'markdown',
370
+ badge: collectionBadge,
371
+ lineWrapping: true,
372
+ },
373
+ parameters: { docs: { source: { code: withBadgesTemplateSource } } },
374
+ };
375
+
282
376
  /**
283
377
  * Parity check: the editor on the left, the same snippet rendered through
284
378
  * `CkMarkdownRenderer` and `UiCodeBlock` on the right. Border, radius, header,
@@ -28,6 +28,7 @@ const props = withDefaults(defineProps<CkCodeEditorProps>(), {
28
28
  lineWrapping: false,
29
29
  maxHeight: undefined,
30
30
  autofocus: false,
31
+ badge: undefined,
31
32
  writeToClipboard: undefined,
32
33
  });
33
34
 
@@ -84,6 +85,7 @@ onMounted(async () => {
84
85
  readonly: isLocked.value,
85
86
  showLineNumbers: props.showLineNumbers,
86
87
  lineWrapping: props.lineWrapping,
88
+ badge: props.badge,
87
89
  onChange: handleChange,
88
90
  onFocus: () => emit('focus'),
89
91
  onBlur: () => emit('blur'),
@@ -128,6 +130,10 @@ watch(
128
130
  () => props.lineWrapping,
129
131
  (wrap) => editor?.setLineWrapping(wrap),
130
132
  );
133
+ watch(
134
+ () => props.badge,
135
+ (rule) => editor?.setBadge(rule),
136
+ );
131
137
 
132
138
  onBeforeUnmount(() => {
133
139
  destroyed = true;
@@ -188,9 +194,9 @@ defineExpose({ focus, getCode });
188
194
  >
189
195
  <div
190
196
  v-if="showHeader"
191
- class="border-border-surface-default bg-background-surface-muted flex items-center justify-between border-b px-4 py-2"
197
+ class="bg-background-surface-muted-subtle flex h-9 items-center justify-between px-3"
192
198
  >
193
- <span class="text-content-on-surface-secondary font-mono text-sm">
199
+ <span class="text-content-on-surface-secondary label-12-regular">
194
200
  {{ headerLabel }}
195
201
  </span>
196
202
  <UiButton
@@ -0,0 +1,173 @@
1
+ import type { Extension, Range } from '@codemirror/state';
2
+ import {
3
+ Decoration,
4
+ type DecorationSet,
5
+ EditorView,
6
+ MatchDecorator,
7
+ ViewPlugin,
8
+ type ViewUpdate,
9
+ WidgetType,
10
+ } from '@codemirror/view';
11
+ import DOMPurify from 'dompurify';
12
+
13
+ /** What a matched reference should look like once it is drawn as a pill. */
14
+ export interface CkCodeEditorBadgeContent {
15
+ /** Text inside the pill. */
16
+ label: string;
17
+ /**
18
+ * Optional inline SVG markup for a leading icon, sized to 12px. This is
19
+ * developer-supplied configuration rather than document content, and it is
20
+ * sanitised before it reaches the DOM.
21
+ */
22
+ icon?: string;
23
+ /** Native tooltip, useful for showing the underlying reference. */
24
+ title?: string;
25
+ /**
26
+ * Classes replacing the default pill styling, which mirrors `UiBadge`'s
27
+ * `outline` variant at `small` size.
28
+ */
29
+ class?: string;
30
+ }
31
+
32
+ /** Rule turning matched text in the document into inline pills. */
33
+ export interface CkCodeEditorBadgeRule {
34
+ /**
35
+ * Pattern matched within each line. A non-global pattern is promoted to a
36
+ * global one, and matching never crosses a line break.
37
+ */
38
+ pattern: RegExp;
39
+ /**
40
+ * Maps a match to its pill, or returns `null` to leave that occurrence as
41
+ * ordinary text. The match array carries any capture groups.
42
+ */
43
+ render: (match: RegExpExecArray) => CkCodeEditorBadgeContent | null;
44
+ }
45
+
46
+ // The badge as the ChatKit design file specifies it: the shadcn `secondary`
47
+ // badge at a 6px radius, 8px/2px padding, a 4px gap and a 12px label. Written
48
+ // out because a widget builds DOM imperatively and cannot mount a Vue component
49
+ // cheaply; these class names sit in chat-kit source, so UnoCSS emits them into
50
+ // the package stylesheet like any other.
51
+ const DEFAULT_BADGE_CLASS =
52
+ 'ck-code-badge inline-flex w-fit shrink-0 select-none items-center justify-center gap-1 ' +
53
+ 'overflow-hidden whitespace-nowrap rounded-md border border-border-surface-default ' +
54
+ 'bg-background-button-secondary-button text-content-on-surface-primary px-2 py-0.5 ' +
55
+ 'label-12-regular align-middle';
56
+
57
+ class BadgeWidget extends WidgetType {
58
+ constructor(private readonly content: CkCodeEditorBadgeContent) {
59
+ super();
60
+ }
61
+
62
+ // Decorations are rebuilt on every document and viewport change. Without a
63
+ // real comparison here every pill would be torn down and recreated on each
64
+ // keystroke, which flickers and drops any focus inside the widget.
65
+ override eq(other: BadgeWidget): boolean {
66
+ return (
67
+ other.content.label === this.content.label &&
68
+ other.content.icon === this.content.icon &&
69
+ other.content.title === this.content.title &&
70
+ other.content.class === this.content.class
71
+ );
72
+ }
73
+
74
+ toDOM(): HTMLElement {
75
+ const el = document.createElement('span');
76
+ el.className = this.content.class ?? DEFAULT_BADGE_CLASS;
77
+ if (this.content.title) el.title = this.content.title;
78
+
79
+ if (this.content.icon) {
80
+ const icon = document.createElement('span');
81
+ icon.className = 'ck-code-badge__icon inline-flex shrink-0';
82
+ // Config, not document content — but sanitised anyway, since it is the
83
+ // only markup in this file that does not originate here.
84
+ icon.innerHTML = DOMPurify.sanitize(this.content.icon, {
85
+ USE_PROFILES: { svg: true, svgFilters: true },
86
+ });
87
+ el.append(icon);
88
+ }
89
+
90
+ el.append(document.createTextNode(this.content.label));
91
+ return el;
92
+ }
93
+
94
+ /** The pill is decorative; let the editor handle every event over it. */
95
+ override ignoreEvent(): boolean {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /** `MatchDecorator` requires a global pattern and mutates `lastIndex`. */
101
+ function toGlobal(pattern: RegExp): RegExp {
102
+ const flags = pattern.flags.includes('g')
103
+ ? pattern.flags
104
+ : `${pattern.flags}g`;
105
+ return new RegExp(pattern.source, flags);
106
+ }
107
+
108
+ /**
109
+ * Draw matched references as inline pills, the way a mention or a chip renders
110
+ * inside prose.
111
+ *
112
+ * The document keeps the original text: the decoration only replaces what is
113
+ * *drawn*, so reading the value, copying and pasting all still yield the raw
114
+ * reference. The matched range is also registered as atomic, so the caret
115
+ * treats a pill as a single unit and backspace removes the whole reference
116
+ * rather than one character of it.
117
+ */
118
+ export function badgeExtension(rule: CkCodeEditorBadgeRule): Extension {
119
+ const matcher = new MatchDecorator({
120
+ regexp: toGlobal(rule.pattern),
121
+ decoration: (match) => {
122
+ const content = rule.render(match as RegExpExecArray);
123
+ return content
124
+ ? Decoration.replace({ widget: new BadgeWidget(content) })
125
+ : null;
126
+ },
127
+ });
128
+
129
+ return ViewPlugin.fromClass(
130
+ class {
131
+ badges: DecorationSet;
132
+
133
+ constructor(view: EditorView) {
134
+ this.badges = matcher.createDeco(view);
135
+ }
136
+
137
+ update(update: ViewUpdate): void {
138
+ this.badges = matcher.updateDeco(update, this.badges);
139
+ }
140
+ },
141
+ {
142
+ decorations: (plugin) => plugin.badges,
143
+ provide: (plugin) =>
144
+ EditorView.atomicRanges.of(
145
+ (view) => view.plugin(plugin)?.badges ?? Decoration.none,
146
+ ),
147
+ },
148
+ );
149
+ }
150
+
151
+ /** Exported for tests: the ranges a rule would decorate in a plain string. */
152
+ export function matchBadgeRanges(
153
+ rule: CkCodeEditorBadgeRule,
154
+ text: string,
155
+ ): Range<Decoration>[] {
156
+ const pattern = toGlobal(rule.pattern);
157
+ const ranges: Range<Decoration>[] = [];
158
+ let match: RegExpExecArray | null;
159
+ while ((match = pattern.exec(text)) !== null) {
160
+ const content = rule.render(match);
161
+ if (content) {
162
+ ranges.push(
163
+ Decoration.replace({ widget: new BadgeWidget(content) }).range(
164
+ match.index,
165
+ match.index + match[0].length,
166
+ ),
167
+ );
168
+ }
169
+ // A zero-length match would spin forever otherwise.
170
+ if (match[0] === '') pattern.lastIndex += 1;
171
+ }
172
+ return ranges;
173
+ }
@@ -22,6 +22,7 @@ import {
22
22
  placeholder as placeholderExt,
23
23
  } from '@codemirror/view';
24
24
  import { ckCodeMirrorTheme } from '../../../helpers/codemirror/theme';
25
+ import { badgeExtension, type CkCodeEditorBadgeRule } from './badgeDecoration';
25
26
  import '../../../helpers/codemirror/code-palette.css';
26
27
 
27
28
  export interface CreateCodeMirrorOptions {
@@ -34,6 +35,8 @@ export interface CreateCodeMirrorOptions {
34
35
  readonly: boolean;
35
36
  showLineNumbers: boolean;
36
37
  lineWrapping: boolean;
38
+ /** Draws matched references as inline pills; see `badgeDecoration.ts`. */
39
+ badge?: CkCodeEditorBadgeRule;
37
40
  /** Called synchronously whenever the document changes. */
38
41
  onChange: (value: string) => void;
39
42
  onFocus: () => void;
@@ -50,6 +53,7 @@ export interface CodeMirrorHandle {
50
53
  setReadonly: (readonly: boolean) => void;
51
54
  setShowLineNumbers: (show: boolean) => void;
52
55
  setLineWrapping: (wrap: boolean) => void;
56
+ setBadge: (rule: CkCodeEditorBadgeRule | undefined) => void;
53
57
  focus: () => void;
54
58
  destroy: () => void;
55
59
  }
@@ -135,6 +139,7 @@ export async function createCodeMirror(
135
139
  const readonlySlot = new Compartment();
136
140
  const lineNumbersSlot = new Compartment();
137
141
  const wrappingSlot = new Compartment();
142
+ const badgeSlot = new Compartment();
138
143
 
139
144
  const support = await resolveLanguage(options.language);
140
145
 
@@ -148,6 +153,7 @@ export async function createCodeMirror(
148
153
  readonlySlot.of(readonlyState(options.readonly)),
149
154
  lineNumbersSlot.of(options.showLineNumbers ? lineNumbers() : []),
150
155
  wrappingSlot.of(options.lineWrapping ? EditorView.lineWrapping : []),
156
+ badgeSlot.of(options.badge ? badgeExtension(options.badge) : []),
151
157
  ],
152
158
  }),
153
159
  });
@@ -182,6 +188,9 @@ export async function createCodeMirror(
182
188
  setLineWrapping: (wrap) => {
183
189
  reconfigure(wrappingSlot, wrap ? EditorView.lineWrapping : []);
184
190
  },
191
+ setBadge: (rule) => {
192
+ reconfigure(badgeSlot, rule ? badgeExtension(rule) : []);
193
+ },
185
194
  focus: () => view.focus(),
186
195
  destroy: () => view.destroy(),
187
196
  };
@@ -1,3 +1,11 @@
1
+ import type { CkCodeEditorBadgeRule } from './badgeDecoration';
2
+
3
+ // Re-exported so consumers get the badge types from the component's entry point.
4
+ export type {
5
+ CkCodeEditorBadgeContent,
6
+ CkCodeEditorBadgeRule,
7
+ } from './badgeDecoration';
8
+
1
9
  /**
2
10
  * Single-document code editor built on [CodeMirror 6](https://codemirror.net/).
3
11
  * Its surface is the one `UiCodeBlock` renders for fenced code inside
@@ -100,6 +108,24 @@ export interface CkCodeEditorProps {
100
108
  * @default false
101
109
  */
102
110
  autofocus?: boolean;
111
+ /**
112
+ * Draws matched text as an inline pill, the way a mention renders in prose.
113
+ * Give a pattern and a callback mapping each match to a label and optional
114
+ * icon; return `null` from the callback to leave an occurrence alone.
115
+ *
116
+ * The document keeps the original text, so `getCode()`, copying and pasting
117
+ * still yield the raw reference. A pill behaves as one unit for the caret,
118
+ * so backspace removes the whole reference.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const badge = {
123
+ * pattern: /@collection:([\w-]+)/g,
124
+ * render: (m) => ({ label: m[1], icon: folderSvg }),
125
+ * };
126
+ * ```
127
+ */
128
+ badge?: CkCodeEditorBadgeRule;
103
129
  /**
104
130
  * Clipboard writer for the copy button. Injected for tests and for hosts that
105
131
  * proxy clipboard access; defaults to `navigator.clipboard.writeText`.