@milkdown/crepe 7.20.0 → 7.21.1

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 (173) hide show
  1. package/lib/cjs/builder.js +1 -0
  2. package/lib/cjs/builder.js.map +1 -1
  3. package/lib/cjs/feature/ai/index.js +1492 -0
  4. package/lib/cjs/feature/ai/index.js.map +1 -0
  5. package/lib/cjs/feature/block-edit/index.js +1 -0
  6. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  7. package/lib/cjs/feature/code-mirror/index.js +1 -0
  8. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  9. package/lib/cjs/feature/cursor/index.js +1 -0
  10. package/lib/cjs/feature/cursor/index.js.map +1 -1
  11. package/lib/cjs/feature/image-block/index.js +1 -0
  12. package/lib/cjs/feature/image-block/index.js.map +1 -1
  13. package/lib/cjs/feature/latex/index.js +2 -0
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +1 -0
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +1 -0
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +1 -0
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +1 -0
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +488 -3
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/feature/top-bar/index.js +1 -0
  26. package/lib/cjs/feature/top-bar/index.js.map +1 -1
  27. package/lib/cjs/index.js +1424 -25
  28. package/lib/cjs/index.js.map +1 -1
  29. package/lib/cjs/llm-providers/anthropic/index.js +152 -0
  30. package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
  31. package/lib/cjs/llm-providers/openai/index.js +143 -0
  32. package/lib/cjs/llm-providers/openai/index.js.map +1 -0
  33. package/lib/esm/builder.js +1 -0
  34. package/lib/esm/builder.js.map +1 -1
  35. package/lib/esm/feature/ai/index.js +1487 -0
  36. package/lib/esm/feature/ai/index.js.map +1 -0
  37. package/lib/esm/feature/block-edit/index.js +1 -0
  38. package/lib/esm/feature/block-edit/index.js.map +1 -1
  39. package/lib/esm/feature/code-mirror/index.js +1 -0
  40. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  41. package/lib/esm/feature/cursor/index.js +1 -0
  42. package/lib/esm/feature/cursor/index.js.map +1 -1
  43. package/lib/esm/feature/image-block/index.js +1 -0
  44. package/lib/esm/feature/image-block/index.js.map +1 -1
  45. package/lib/esm/feature/latex/index.js +2 -0
  46. package/lib/esm/feature/latex/index.js.map +1 -1
  47. package/lib/esm/feature/link-tooltip/index.js +1 -0
  48. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  49. package/lib/esm/feature/list-item/index.js +1 -0
  50. package/lib/esm/feature/list-item/index.js.map +1 -1
  51. package/lib/esm/feature/placeholder/index.js +1 -0
  52. package/lib/esm/feature/placeholder/index.js.map +1 -1
  53. package/lib/esm/feature/table/index.js +1 -0
  54. package/lib/esm/feature/table/index.js.map +1 -1
  55. package/lib/esm/feature/toolbar/index.js +490 -5
  56. package/lib/esm/feature/toolbar/index.js.map +1 -1
  57. package/lib/esm/feature/top-bar/index.js +1 -0
  58. package/lib/esm/feature/top-bar/index.js.map +1 -1
  59. package/lib/esm/index.js +1414 -15
  60. package/lib/esm/index.js.map +1 -1
  61. package/lib/esm/llm-providers/anthropic/index.js +150 -0
  62. package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
  63. package/lib/esm/llm-providers/openai/index.js +141 -0
  64. package/lib/esm/llm-providers/openai/index.js.map +1 -0
  65. package/lib/theme/common/ai.css +446 -0
  66. package/lib/theme/common/code-mirror.css +14 -0
  67. package/lib/theme/common/diff.css +177 -0
  68. package/lib/theme/common/style.css +2 -0
  69. package/lib/tsconfig.tsbuildinfo +1 -1
  70. package/lib/types/feature/ai/ai.spec.d.ts +2 -0
  71. package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
  72. package/lib/types/feature/ai/commands.d.ts +24 -0
  73. package/lib/types/feature/ai/commands.d.ts.map +1 -0
  74. package/lib/types/feature/ai/context.d.ts +4 -0
  75. package/lib/types/feature/ai/context.d.ts.map +1 -0
  76. package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
  77. package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
  78. package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
  79. package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
  80. package/lib/types/feature/ai/index.d.ts +7 -0
  81. package/lib/types/feature/ai/index.d.ts.map +1 -0
  82. package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
  83. package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
  84. package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
  85. package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
  86. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
  87. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
  88. package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
  89. package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
  90. package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
  91. package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
  92. package/lib/types/feature/ai/types.d.ts +58 -0
  93. package/lib/types/feature/ai/types.d.ts.map +1 -0
  94. package/lib/types/feature/index.d.ts +4 -1
  95. package/lib/types/feature/index.d.ts.map +1 -1
  96. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
  97. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
  98. package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
  99. package/lib/types/feature/loader.d.ts.map +1 -1
  100. package/lib/types/feature/toolbar/config.d.ts.map +1 -1
  101. package/lib/types/feature/toolbar/index.d.ts +1 -0
  102. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  103. package/lib/types/icons/ai.d.ts +2 -0
  104. package/lib/types/icons/ai.d.ts.map +1 -0
  105. package/lib/types/icons/chevron-left.d.ts +2 -0
  106. package/lib/types/icons/chevron-left.d.ts.map +1 -0
  107. package/lib/types/icons/chevron-right.d.ts +2 -0
  108. package/lib/types/icons/chevron-right.d.ts.map +1 -0
  109. package/lib/types/icons/enter-key.d.ts +2 -0
  110. package/lib/types/icons/enter-key.d.ts.map +1 -0
  111. package/lib/types/icons/grammar-check.d.ts +2 -0
  112. package/lib/types/icons/grammar-check.d.ts.map +1 -0
  113. package/lib/types/icons/index.d.ts +11 -0
  114. package/lib/types/icons/index.d.ts.map +1 -1
  115. package/lib/types/icons/longer.d.ts +2 -0
  116. package/lib/types/icons/longer.d.ts.map +1 -0
  117. package/lib/types/icons/retry.d.ts +2 -0
  118. package/lib/types/icons/retry.d.ts.map +1 -0
  119. package/lib/types/icons/send-prompt.d.ts +2 -0
  120. package/lib/types/icons/send-prompt.d.ts.map +1 -0
  121. package/lib/types/icons/send.d.ts +2 -0
  122. package/lib/types/icons/send.d.ts.map +1 -0
  123. package/lib/types/icons/shorter.d.ts +2 -0
  124. package/lib/types/icons/shorter.d.ts.map +1 -0
  125. package/lib/types/icons/translate.d.ts +2 -0
  126. package/lib/types/icons/translate.d.ts.map +1 -0
  127. package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
  128. package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
  129. package/lib/types/llm-providers/openai/index.d.ts +15 -0
  130. package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
  131. package/lib/types/llm-providers/providers.spec.d.ts +2 -0
  132. package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
  133. package/lib/types/llm-providers/shared.d.ts +17 -0
  134. package/lib/types/llm-providers/shared.d.ts.map +1 -0
  135. package/package.json +18 -2
  136. package/src/feature/ai/ai.spec.ts +742 -0
  137. package/src/feature/ai/commands.ts +257 -0
  138. package/src/feature/ai/context.ts +45 -0
  139. package/src/feature/ai/diff-actions/index.ts +95 -0
  140. package/src/feature/ai/diff-actions/view.ts +237 -0
  141. package/src/feature/ai/index.ts +118 -0
  142. package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
  143. package/src/feature/ai/instruction-tooltip/index.ts +101 -0
  144. package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
  145. package/src/feature/ai/instruction-tooltip/view.ts +159 -0
  146. package/src/feature/ai/streaming-indicator.ts +183 -0
  147. package/src/feature/ai/types.ts +178 -0
  148. package/src/feature/index.ts +8 -2
  149. package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
  150. package/src/feature/latex/inline-tooltip/view.ts +2 -0
  151. package/src/feature/loader.ts +4 -0
  152. package/src/feature/toolbar/config.ts +27 -1
  153. package/src/feature/toolbar/index.ts +1 -0
  154. package/src/icons/ai.ts +14 -0
  155. package/src/icons/chevron-left.ts +15 -0
  156. package/src/icons/chevron-right.ts +15 -0
  157. package/src/icons/enter-key.ts +13 -0
  158. package/src/icons/grammar-check.ts +13 -0
  159. package/src/icons/index.ts +11 -0
  160. package/src/icons/longer.ts +13 -0
  161. package/src/icons/retry.ts +13 -0
  162. package/src/icons/send-prompt.ts +13 -0
  163. package/src/icons/send.ts +13 -0
  164. package/src/icons/shorter.ts +13 -0
  165. package/src/icons/translate.ts +13 -0
  166. package/src/llm-providers/anthropic/index.ts +133 -0
  167. package/src/llm-providers/openai/index.ts +110 -0
  168. package/src/llm-providers/providers.spec.ts +472 -0
  169. package/src/llm-providers/shared.ts +170 -0
  170. package/src/theme/common/ai.css +430 -0
  171. package/src/theme/common/code-mirror.css +14 -0
  172. package/src/theme/common/diff.css +196 -0
  173. package/src/theme/common/style.css +2 -0
@@ -0,0 +1,118 @@
1
+ import {
2
+ diffComponent,
3
+ diffComponentConfig,
4
+ } from '@milkdown/kit/component/diff'
5
+ import { diff, diffConfig } from '@milkdown/kit/plugin/diff'
6
+ import { streaming, streamingConfig } from '@milkdown/kit/plugin/streaming'
7
+
8
+ import type { DefineFeature } from '../shared'
9
+ import type { AIFeatureConfig } from './types'
10
+
11
+ import { crepeFeatureConfig } from '../../core/slice'
12
+ import { CrepeFeature } from '../index'
13
+ import {
14
+ aiProviderConfig,
15
+ aiSessionCtx,
16
+ abortAICmd,
17
+ runAICmd,
18
+ } from './commands'
19
+ import { diffActionsPanelPlugin } from './diff-actions'
20
+ import {
21
+ aiInstructionTooltip,
22
+ aiInstructionTooltipAPI,
23
+ configureAIInstructionTooltip,
24
+ } from './instruction-tooltip'
25
+ import { streamingIndicatorPlugin } from './streaming-indicator'
26
+
27
+ /// Default node types in Crepe that use custom node views.
28
+ const CREPE_CUSTOM_BLOCK_TYPES = ['table', 'image-block', 'code_block']
29
+
30
+ /// Default attrs to ignore in Crepe's diff and streaming features.
31
+ const CREPE_IGNORE_ATTRS: Record<string, string[]> = { heading: ['id'] }
32
+
33
+ export type {
34
+ AIDiffActionsConfig,
35
+ AIFeatureConfig,
36
+ AIPromptContext,
37
+ AIProvider,
38
+ AIStreamingIndicatorConfig,
39
+ AISubmenuBuilder,
40
+ AISubmenuDef,
41
+ AISuggestionItem,
42
+ AISuggestionsBuilder,
43
+ } from './types'
44
+ export { runAICmd, abortAICmd } from './commands'
45
+ export { defaultBuildContext } from './context'
46
+
47
+ export const ai: DefineFeature<AIFeatureConfig> = (editor, config) => {
48
+ const diffCfg = config?.diff ?? {}
49
+ const streamingCfg = config?.streaming ?? {}
50
+
51
+ editor
52
+ .config(crepeFeatureConfig(CrepeFeature.AI))
53
+ // -- Diff plugin + component --
54
+ .config((ctx) => {
55
+ ctx.update(diffConfig.key, (prev) => ({
56
+ ...prev,
57
+ ignoreAttrs: diffCfg.ignoreAttrs ?? CREPE_IGNORE_ATTRS,
58
+ }))
59
+ const { ignoreAttrs: _, ...componentConfig } = diffCfg
60
+ ctx.update(diffComponentConfig.key, (prev) => ({
61
+ ...prev,
62
+ customBlockTypes:
63
+ componentConfig.customBlockTypes ?? CREPE_CUSTOM_BLOCK_TYPES,
64
+ ...componentConfig,
65
+ }))
66
+ })
67
+ .use(diff)
68
+ .use(diffComponent)
69
+ // -- Streaming plugin --
70
+ .config((ctx) => {
71
+ ctx.update(streamingConfig.key, (prev) => ({
72
+ ...prev,
73
+ ...streamingCfg,
74
+ ignoreAttrs: streamingCfg.ignoreAttrs ?? CREPE_IGNORE_ATTRS,
75
+ // Wire diffReviewOnEnd into the streaming plugin so manual
76
+ // endStreamingCmd calls (outside runAICmd) also respect it.
77
+ // Only override if the user explicitly set it — otherwise
78
+ // keep the streaming plugin's own default so tests and
79
+ // manual-streaming use cases aren't surprised.
80
+ ...(config?.diffReviewOnEnd !== undefined
81
+ ? { diffReviewOnEnd: config.diffReviewOnEnd }
82
+ : {}),
83
+ }))
84
+ })
85
+ .use(streaming)
86
+ // -- AI orchestration --
87
+ .config((ctx) => {
88
+ ctx.update(aiProviderConfig.key, (prev) => ({
89
+ ...prev,
90
+ ...(config?.provider !== undefined
91
+ ? { provider: config.provider }
92
+ : {}),
93
+ ...(config?.buildContext !== undefined
94
+ ? { buildContext: config.buildContext }
95
+ : {}),
96
+ diffReviewOnEnd: config?.diffReviewOnEnd ?? prev.diffReviewOnEnd,
97
+ ...(config?.onError !== undefined ? { onError: config.onError } : {}),
98
+ ...(config?.aiIcon !== undefined ? { aiIcon: config.aiIcon } : {}),
99
+ }))
100
+ })
101
+ .use(aiProviderConfig)
102
+ .use(aiSessionCtx)
103
+ .use(runAICmd)
104
+ .use(abortAICmd)
105
+ // -- AI instruction tooltip --
106
+ .config(configureAIInstructionTooltip(config))
107
+ .use(aiInstructionTooltipAPI)
108
+ .use(aiInstructionTooltip)
109
+ // -- Streaming indicator --
110
+ .use(streamingIndicatorPlugin({ config: config?.streamingIndicator }))
111
+ // -- Diff actions panel --
112
+ .use(
113
+ diffActionsPanelPlugin({
114
+ config: config?.diffActions,
115
+ enterKeyIcon: config?.enterKeyIcon,
116
+ })
117
+ )
118
+ }
@@ -0,0 +1,414 @@
1
+ import { Icon } from '@milkdown/kit/component'
2
+ import {
3
+ computed,
4
+ defineComponent,
5
+ h,
6
+ nextTick,
7
+ ref,
8
+ watch,
9
+ type Ref,
10
+ } from 'vue'
11
+
12
+ import type { AISuggestionItem, ResolvedSuggestions } from './suggestions'
13
+
14
+ import { keepAlive } from '../../../utils/keep-alive'
15
+
16
+ keepAlive(h)
17
+
18
+ /// Resolved chrome (icons + labels) for the tooltip. All fields required —
19
+ /// the view layer fills in defaults before mounting the component.
20
+ export interface AIInstructionTooltipChrome {
21
+ aiIcon: string
22
+ sendIcon: string
23
+ sendPromptIcon: string
24
+ enterKeyIcon: string
25
+ chevronLeftIcon: string
26
+ chevronRightIcon: string
27
+ suggestionsHeaderLabel: string
28
+ sendAsPromptHeaderLabel: string
29
+ sendAsPromptLabel: string
30
+ submitButtonLabel: string
31
+ listboxLabel: string
32
+ }
33
+
34
+ type ViewMode = { kind: 'main' } | { kind: 'submenu'; id: string }
35
+
36
+ interface DisplayItem {
37
+ id: string
38
+ icon: string
39
+ label: string
40
+ hasSubmenu: boolean
41
+ /// undefined when the item opens a submenu; defined when it sends a prompt
42
+ prompt?: { text: string; streamingLabel?: string }
43
+ }
44
+
45
+ function renderHighlighted(label: string, query: string) {
46
+ const q = query.trim()
47
+ if (!q) return [label]
48
+ const lower = label.toLowerCase()
49
+ const lq = q.toLowerCase()
50
+ const idx = lower.indexOf(lq)
51
+ if (idx === -1) return [label]
52
+ return [
53
+ label.slice(0, idx),
54
+ h('mark', null, label.slice(idx, idx + q.length)),
55
+ label.slice(idx + q.length),
56
+ ]
57
+ }
58
+
59
+ type AIInstructionInputProps = {
60
+ placeholder: Ref<string>
61
+ /// Bumped by the view layer every time the tooltip is shown, so the
62
+ /// component can reset transient state (input value, submenu, cursor).
63
+ resetSignal: Ref<number>
64
+ suggestions: ResolvedSuggestions
65
+ chrome: AIInstructionTooltipChrome
66
+ onConfirm: (instruction: string, label?: string) => void
67
+ onCancel: () => void
68
+ }
69
+
70
+ export const AIInstructionInput = defineComponent<AIInstructionInputProps>({
71
+ props: {
72
+ placeholder: { type: Object, required: true },
73
+ resetSignal: { type: Object, required: true },
74
+ suggestions: { type: Object, required: true },
75
+ chrome: { type: Object, required: true },
76
+ onConfirm: { type: Function, required: true },
77
+ onCancel: { type: Function, required: true },
78
+ },
79
+ setup({
80
+ placeholder,
81
+ resetSignal,
82
+ suggestions,
83
+ chrome,
84
+ onConfirm,
85
+ onCancel,
86
+ }) {
87
+ const inputValue = ref('')
88
+ const view = ref<ViewMode>({ kind: 'main' })
89
+ const selectedIndex = ref(0)
90
+ const inputRef = ref<HTMLInputElement | null>(null)
91
+ const listRef = ref<HTMLDivElement | null>(null)
92
+
93
+ // Stable id pair used to wire the input ↔ listbox combobox so that
94
+ // screen readers announce the highlighted option as the user moves
95
+ // through the suggestions with the arrow keys.
96
+ const listboxId = `ai-instruction-list-${Math.random().toString(36).slice(2, 9)}`
97
+ const optionId = (idx: number) => `${listboxId}-opt-${idx}`
98
+
99
+ watch(resetSignal, () => {
100
+ inputValue.value = ''
101
+ view.value = { kind: 'main' }
102
+ selectedIndex.value = 0
103
+ })
104
+
105
+ const allItems = computed<DisplayItem[]>(() => {
106
+ if (view.value.kind === 'submenu') {
107
+ const submenu = suggestions.submenus[view.value.id]
108
+ if (!submenu) return []
109
+ return submenu.items.map(({ id, item }) => ({
110
+ id,
111
+ icon: item.icon,
112
+ label: item.label,
113
+ hasSubmenu: false,
114
+ prompt: { text: item.prompt, streamingLabel: item.streamingLabel },
115
+ }))
116
+ }
117
+ return suggestions.main.map((entry) => {
118
+ if (entry.kind === 'item') {
119
+ return {
120
+ id: entry.id,
121
+ icon: entry.item.icon,
122
+ label: entry.item.label,
123
+ hasSubmenu: false,
124
+ prompt: {
125
+ text: entry.item.prompt,
126
+ streamingLabel: entry.item.streamingLabel,
127
+ },
128
+ }
129
+ }
130
+ return {
131
+ id: entry.id,
132
+ icon: entry.def.icon,
133
+ label: entry.def.label,
134
+ hasSubmenu: true,
135
+ }
136
+ })
137
+ })
138
+
139
+ const currentSubmenuDef = computed(() => {
140
+ if (view.value.kind !== 'submenu') return null
141
+ return suggestions.submenus[view.value.id]?.def ?? null
142
+ })
143
+
144
+ const filteredItems = computed(() => {
145
+ const q = inputValue.value.trim().toLowerCase()
146
+ if (!q) return allItems.value
147
+ return allItems.value.filter((item) =>
148
+ item.label.toLowerCase().includes(q)
149
+ )
150
+ })
151
+
152
+ const showSendAsPrompt = computed(() => inputValue.value.trim().length > 0)
153
+
154
+ const totalItems = computed(
155
+ () => filteredItems.value.length + (showSendAsPrompt.value ? 1 : 0)
156
+ )
157
+
158
+ watch([filteredItems, view, showSendAsPrompt], () => {
159
+ // When the user has typed something, default the highlight to the
160
+ // "Send as prompt" row (always last in the list) so pressing Enter
161
+ // sends the typed text instead of running whichever built-in
162
+ // suggestion happens to substring-match the query.
163
+ selectedIndex.value = showSendAsPrompt.value
164
+ ? filteredItems.value.length
165
+ : 0
166
+ })
167
+
168
+ const focusInput = () => {
169
+ void nextTick(() => inputRef.value?.focus())
170
+ }
171
+
172
+ const enterSubmenu = (id: string) => {
173
+ view.value = { kind: 'submenu', id }
174
+ inputValue.value = ''
175
+ selectedIndex.value = 0
176
+ focusInput()
177
+ }
178
+
179
+ const exitSubmenu = () => {
180
+ view.value = { kind: 'main' }
181
+ inputValue.value = ''
182
+ selectedIndex.value = 0
183
+ focusInput()
184
+ }
185
+
186
+ const runItem = (item: DisplayItem) => {
187
+ if (item.hasSubmenu) {
188
+ enterSubmenu(item.id)
189
+ } else if (item.prompt) {
190
+ onConfirm(item.prompt.text, item.prompt.streamingLabel)
191
+ inputValue.value = ''
192
+ }
193
+ }
194
+
195
+ const submitRaw = () => {
196
+ const v = inputValue.value.trim()
197
+ if (!v) return
198
+ onConfirm(v)
199
+ inputValue.value = ''
200
+ }
201
+
202
+ const onSelectCurrent = () => {
203
+ const idx = selectedIndex.value
204
+ const items = filteredItems.value
205
+ if (idx < items.length) {
206
+ runItem(items[idx]!)
207
+ } else if (showSendAsPrompt.value) {
208
+ submitRaw()
209
+ }
210
+ }
211
+
212
+ const scrollToSelected = () => {
213
+ void nextTick(() => {
214
+ const list = listRef.value
215
+ if (!list) return
216
+ const el = list.querySelector(
217
+ `[data-index="${selectedIndex.value}"]`
218
+ ) as HTMLElement | null
219
+ el?.scrollIntoView({ block: 'nearest' })
220
+ })
221
+ }
222
+
223
+ const onKeydown = (e: KeyboardEvent) => {
224
+ e.stopPropagation()
225
+
226
+ if (e.key === 'ArrowDown') {
227
+ e.preventDefault()
228
+ if (totalItems.value === 0) return
229
+ selectedIndex.value = (selectedIndex.value + 1) % totalItems.value
230
+ scrollToSelected()
231
+ return
232
+ }
233
+
234
+ if (e.key === 'ArrowUp') {
235
+ e.preventDefault()
236
+ if (totalItems.value === 0) return
237
+ selectedIndex.value =
238
+ (selectedIndex.value - 1 + totalItems.value) % totalItems.value
239
+ scrollToSelected()
240
+ return
241
+ }
242
+
243
+ if (e.key === 'Enter') {
244
+ e.preventDefault()
245
+ onSelectCurrent()
246
+ return
247
+ }
248
+
249
+ if (e.key === 'Escape') {
250
+ e.preventDefault()
251
+ if (view.value.kind === 'submenu') exitSubmenu()
252
+ else onCancel()
253
+ return
254
+ }
255
+
256
+ if (
257
+ e.key === 'Backspace' &&
258
+ inputValue.value === '' &&
259
+ view.value.kind === 'submenu'
260
+ ) {
261
+ e.preventDefault()
262
+ exitSubmenu()
263
+ }
264
+ }
265
+
266
+ const onItemPointerDown = (e: Event) => {
267
+ e.preventDefault()
268
+ }
269
+
270
+ return () => {
271
+ const items = filteredItems.value
272
+ const showPrompt = showSendAsPrompt.value
273
+ const submenuDef = currentSubmenuDef.value
274
+
275
+ return (
276
+ <div class="ai-instruction">
277
+ <div class="ai-instruction-input">
278
+ <span class="ai-instruction-input-prefix">
279
+ <Icon icon={chrome.aiIcon} />
280
+ </span>
281
+ <input
282
+ ref={inputRef}
283
+ class="ai-instruction-input-field"
284
+ role="combobox"
285
+ aria-expanded="true"
286
+ aria-autocomplete="list"
287
+ aria-controls={listboxId}
288
+ aria-activedescendant={
289
+ totalItems.value > 0 ? optionId(selectedIndex.value) : undefined
290
+ }
291
+ placeholder={
292
+ submenuDef ? submenuDef.searchPlaceholder : placeholder.value
293
+ }
294
+ value={inputValue.value}
295
+ onInput={(e: Event) => {
296
+ inputValue.value = (e.target as HTMLInputElement).value
297
+ }}
298
+ onKeydown={onKeydown}
299
+ />
300
+ <button
301
+ type="button"
302
+ class="ai-instruction-submit"
303
+ aria-label={chrome.submitButtonLabel}
304
+ disabled={!showPrompt}
305
+ onMousedown={onItemPointerDown}
306
+ onClick={submitRaw}
307
+ >
308
+ <Icon icon={chrome.sendIcon} />
309
+ </button>
310
+ </div>
311
+
312
+ <div
313
+ class="ai-instruction-list"
314
+ ref={listRef}
315
+ id={listboxId}
316
+ role="listbox"
317
+ aria-label={chrome.listboxLabel}
318
+ >
319
+ {submenuDef && (
320
+ <button
321
+ type="button"
322
+ class="ai-instruction-back"
323
+ onMousedown={onItemPointerDown}
324
+ onClick={exitSubmenu}
325
+ >
326
+ <span class="ai-instruction-back-icon" aria-hidden="true">
327
+ <Icon icon={chrome.chevronLeftIcon} />
328
+ </span>
329
+ <span>{submenuDef.title}</span>
330
+ </button>
331
+ )}
332
+
333
+ {items.length > 0 && (
334
+ <div class="ai-instruction-section">
335
+ <div class="ai-instruction-section-header">
336
+ {chrome.suggestionsHeaderLabel}
337
+ </div>
338
+ {items.map((item, idx) => (
339
+ <div
340
+ key={item.id}
341
+ id={optionId(idx)}
342
+ data-index={idx}
343
+ role="option"
344
+ aria-selected={idx === selectedIndex.value}
345
+ class={[
346
+ 'ai-instruction-item',
347
+ idx === selectedIndex.value ? 'active' : '',
348
+ ]}
349
+ onMousedown={onItemPointerDown}
350
+ onClick={() => runItem(item)}
351
+ onPointerenter={() => {
352
+ selectedIndex.value = idx
353
+ }}
354
+ >
355
+ <span class="ai-instruction-item-icon">
356
+ <Icon icon={item.icon} />
357
+ </span>
358
+ <span class="ai-instruction-item-label">
359
+ {renderHighlighted(item.label, inputValue.value)}
360
+ </span>
361
+ {item.hasSubmenu && (
362
+ <span class="ai-instruction-item-arrow">
363
+ <Icon icon={chrome.chevronRightIcon} />
364
+ </span>
365
+ )}
366
+ </div>
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ {showPrompt && (
372
+ <div class="ai-instruction-section">
373
+ <div class="ai-instruction-section-header">
374
+ {chrome.sendAsPromptHeaderLabel}
375
+ </div>
376
+ <div
377
+ id={optionId(items.length)}
378
+ data-index={items.length}
379
+ role="option"
380
+ aria-selected={selectedIndex.value === items.length}
381
+ class={[
382
+ 'ai-instruction-item',
383
+ 'ai-instruction-item-prompt',
384
+ selectedIndex.value === items.length ? 'active' : '',
385
+ ]}
386
+ onMousedown={onItemPointerDown}
387
+ onClick={submitRaw}
388
+ onPointerenter={() => {
389
+ selectedIndex.value = items.length
390
+ }}
391
+ >
392
+ <span class="ai-instruction-item-icon">
393
+ <Icon icon={chrome.sendPromptIcon} />
394
+ </span>
395
+ <span class="ai-instruction-item-label">
396
+ {chrome.sendAsPromptLabel}{' '}
397
+ <span class="ai-instruction-item-quote">
398
+ "{inputValue.value}"
399
+ </span>
400
+ </span>
401
+ <span class="ai-instruction-item-shortcut">
402
+ <Icon icon={chrome.enterKeyIcon} />
403
+ </span>
404
+ </div>
405
+ </div>
406
+ )}
407
+ </div>
408
+ </div>
409
+ )
410
+ }
411
+ },
412
+ })
413
+
414
+ export type { AISuggestionItem }
@@ -0,0 +1,101 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+
3
+ import { tooltipFactory } from '@milkdown/kit/plugin/tooltip'
4
+ import { $ctx } from '@milkdown/kit/utils'
5
+
6
+ import type { AIFeatureConfig } from '../types'
7
+ import type { AIInstructionTooltipChrome } from './component'
8
+
9
+ import {
10
+ aiIcon as defaultAiIcon,
11
+ chevronLeftIcon as defaultChevronLeftIcon,
12
+ chevronRightIcon as defaultChevronRightIcon,
13
+ enterKeyIcon as defaultEnterKeyIcon,
14
+ sendIcon as defaultSendIcon,
15
+ sendPromptIcon as defaultSendPromptIcon,
16
+ } from '../../../icons'
17
+ import { AISuggestionsBuilder, applyDefaultSuggestions } from './suggestions'
18
+ import {
19
+ AIInstructionTooltipView,
20
+ type AIInstructionTooltipViewConfig,
21
+ } from './view'
22
+
23
+ export interface AIInstructionTooltipAPI {
24
+ show: (from: number, to: number) => void
25
+ }
26
+
27
+ const defaultAPI: AIInstructionTooltipAPI = {
28
+ show: () => {},
29
+ }
30
+
31
+ export const aiInstructionTooltipAPI = $ctx(
32
+ { ...defaultAPI },
33
+ 'aiInstructionTooltipAPI'
34
+ )
35
+
36
+ export const aiInstructionTooltip = tooltipFactory('CREPE_AI_INSTRUCTION')
37
+
38
+ /// Default strings for the instruction tooltip. Exported so the JSDoc on
39
+ /// `AIFeatureConfig` and consumers stay in sync with what the resolver
40
+ /// actually uses.
41
+ export const DEFAULT_SUGGESTIONS_HEADER_LABEL = 'SUGGESTIONS'
42
+ export const DEFAULT_SEND_AS_PROMPT_HEADER_LABEL = 'SEND AS PROMPT'
43
+ export const DEFAULT_SEND_AS_PROMPT_LABEL = 'Ask AI:'
44
+ export const DEFAULT_SUBMIT_BUTTON_LABEL = 'Send prompt'
45
+ export const DEFAULT_LISTBOX_LABEL = 'AI suggestions'
46
+ export const DEFAULT_INSTRUCTION_PLACEHOLDER =
47
+ 'Tell AI what to do with the selection…'
48
+
49
+ function resolveChrome(config?: AIFeatureConfig): AIInstructionTooltipChrome {
50
+ return {
51
+ aiIcon: config?.aiIcon ?? defaultAiIcon,
52
+ sendIcon: config?.sendIcon ?? defaultSendIcon,
53
+ sendPromptIcon: config?.sendPromptIcon ?? defaultSendPromptIcon,
54
+ enterKeyIcon: config?.enterKeyIcon ?? defaultEnterKeyIcon,
55
+ chevronLeftIcon: config?.chevronLeftIcon ?? defaultChevronLeftIcon,
56
+ chevronRightIcon: config?.chevronRightIcon ?? defaultChevronRightIcon,
57
+ suggestionsHeaderLabel:
58
+ config?.suggestionsHeaderLabel ?? DEFAULT_SUGGESTIONS_HEADER_LABEL,
59
+ sendAsPromptHeaderLabel:
60
+ config?.sendAsPromptHeaderLabel ?? DEFAULT_SEND_AS_PROMPT_HEADER_LABEL,
61
+ sendAsPromptLabel:
62
+ config?.sendAsPromptLabel ?? DEFAULT_SEND_AS_PROMPT_LABEL,
63
+ submitButtonLabel: config?.submitButtonLabel ?? DEFAULT_SUBMIT_BUTTON_LABEL,
64
+ listboxLabel: config?.listboxLabel ?? DEFAULT_LISTBOX_LABEL,
65
+ }
66
+ }
67
+
68
+ function resolveViewConfig(
69
+ config?: AIFeatureConfig
70
+ ): AIInstructionTooltipViewConfig {
71
+ const builder = new AISuggestionsBuilder()
72
+ applyDefaultSuggestions(builder)
73
+ config?.buildAISuggestions?.(builder)
74
+ return {
75
+ placeholder:
76
+ config?.instructionPlaceholder ?? DEFAULT_INSTRUCTION_PLACEHOLDER,
77
+ chrome: resolveChrome(config),
78
+ suggestions: builder.build(),
79
+ }
80
+ }
81
+
82
+ export function configureAIInstructionTooltip(config?: AIFeatureConfig) {
83
+ return (ctx: Ctx) => {
84
+ const viewConfig = resolveViewConfig(config)
85
+ let tooltipView: AIInstructionTooltipView | null = null
86
+
87
+ ctx.update(aiInstructionTooltipAPI.key, (api) => ({
88
+ ...api,
89
+ show: (from, to) => {
90
+ tooltipView?.show(from, to)
91
+ },
92
+ }))
93
+
94
+ ctx.set(aiInstructionTooltip.key, {
95
+ view: (view) => {
96
+ tooltipView = new AIInstructionTooltipView(ctx, view, viewConfig)
97
+ return tooltipView
98
+ },
99
+ })
100
+ }
101
+ }