@gram-ai/elements 1.21.3 → 1.22.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 (35) hide show
  1. package/dist/components/Chat/stories/ToolMentions.stories.d.ts +19 -0
  2. package/dist/components/assistant-ui/mentioned-tools-badges.d.ts +10 -0
  3. package/dist/components/assistant-ui/tool-mention-autocomplete.d.ts +12 -0
  4. package/dist/elements.cjs +1 -1
  5. package/dist/elements.css +1 -1
  6. package/dist/elements.js +1 -1
  7. package/dist/hooks/useToolMentions.d.ts +17 -0
  8. package/dist/{index-D5cSC22A.cjs → index-DSGCuihM.cjs} +47 -46
  9. package/dist/index-DSGCuihM.cjs.map +1 -0
  10. package/dist/{index-CKBUBkLd.js → index-DiaPGyWF.js} +11034 -10678
  11. package/dist/index-DiaPGyWF.js.map +1 -0
  12. package/dist/index.d.ts +1 -1
  13. package/dist/lib/tool-mentions.d.ts +20 -0
  14. package/dist/{profiler-BY6U7KRN.cjs → profiler-Cr1UEa8B.cjs} +2 -2
  15. package/dist/{profiler-BY6U7KRN.cjs.map → profiler-Cr1UEa8B.cjs.map} +1 -1
  16. package/dist/{profiler-CRozVGVC.js → profiler-DL_h66-O.js} +2 -2
  17. package/dist/{profiler-CRozVGVC.js.map → profiler-DL_h66-O.js.map} +1 -1
  18. package/dist/{startRecording-DQXktVbB.js → startRecording-BW2zkd0-.js} +2 -2
  19. package/dist/{startRecording-DQXktVbB.js.map → startRecording-BW2zkd0-.js.map} +1 -1
  20. package/dist/{startRecording-Bi82O_dZ.cjs → startRecording-D8-ckYMS.cjs} +2 -2
  21. package/dist/{startRecording-Bi82O_dZ.cjs.map → startRecording-D8-ckYMS.cjs.map} +1 -1
  22. package/dist/types/index.d.ts +14 -0
  23. package/package.json +1 -1
  24. package/src/components/Chat/stories/ToolMentions.stories.tsx +62 -0
  25. package/src/components/assistant-ui/mentioned-tools-badges.tsx +105 -0
  26. package/src/components/assistant-ui/thread.tsx +106 -1
  27. package/src/components/assistant-ui/tool-mention-autocomplete.tsx +253 -0
  28. package/src/components/ui/tool-ui.tsx +1 -1
  29. package/src/contexts/ElementsProvider.tsx +2 -1
  30. package/src/hooks/useToolMentions.ts +93 -0
  31. package/src/index.ts +1 -0
  32. package/src/lib/tool-mentions.ts +106 -0
  33. package/src/types/index.ts +16 -0
  34. package/dist/index-CKBUBkLd.js.map +0 -1
  35. package/dist/index-D5cSC22A.cjs.map +0 -1
@@ -0,0 +1,253 @@
1
+ import { FC, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Wrench } from 'lucide-react'
3
+ import * as m from 'motion/react-m'
4
+
5
+ import { cn } from '@/lib/utils'
6
+ import { useDensity } from '@/hooks/useDensity'
7
+ import { useRadius } from '@/hooks/useRadius'
8
+ import { EASE_OUT_QUINT } from '@/lib/easing'
9
+ import {
10
+ MentionableTool,
11
+ detectMentionContext,
12
+ filterToolsByQuery,
13
+ insertToolMention,
14
+ } from '@/lib/tool-mentions'
15
+
16
+ export interface ToolMentionAutocompleteProps {
17
+ tools: MentionableTool[]
18
+ value: string
19
+ cursorPosition: number
20
+ onValueChange: (value: string, cursorPosition: number) => void
21
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>
22
+ className?: string
23
+ }
24
+
25
+ export const ToolMentionAutocomplete: FC<ToolMentionAutocompleteProps> = ({
26
+ tools,
27
+ value,
28
+ cursorPosition,
29
+ onValueChange,
30
+ textareaRef,
31
+ className,
32
+ }) => {
33
+ const [selectedIndex, setSelectedIndex] = useState(0)
34
+ const [isVisible, setIsVisible] = useState(false)
35
+ const [filteredTools, setFilteredTools] = useState<MentionableTool[]>([])
36
+ const [mentionContext, setMentionContext] = useState<{
37
+ atPosition: number
38
+ query: string
39
+ } | null>(null)
40
+
41
+ const containerRef = useRef<HTMLDivElement>(null)
42
+ const d = useDensity()
43
+ const r = useRadius()
44
+
45
+ useEffect(() => {
46
+ const context = detectMentionContext(value, cursorPosition)
47
+
48
+ if (context.isInMention && tools.length > 0) {
49
+ const filtered = filterToolsByQuery(tools, context.query)
50
+ setFilteredTools(filtered)
51
+ setIsVisible(filtered.length > 0)
52
+ setMentionContext({
53
+ atPosition: context.atPosition,
54
+ query: context.query,
55
+ })
56
+ setSelectedIndex(0)
57
+ } else {
58
+ setIsVisible(false)
59
+ setMentionContext(null)
60
+ }
61
+ }, [value, cursorPosition, tools])
62
+
63
+ const selectTool = useCallback(
64
+ (tool: MentionableTool) => {
65
+ if (!mentionContext) return
66
+
67
+ const result = insertToolMention(
68
+ value,
69
+ tool.name,
70
+ mentionContext.atPosition,
71
+ cursorPosition
72
+ )
73
+
74
+ onValueChange(result.text, result.cursorPosition)
75
+ setIsVisible(false)
76
+ },
77
+ [mentionContext, value, cursorPosition, onValueChange, textareaRef]
78
+ )
79
+
80
+ useEffect(() => {
81
+ if (!isVisible) return
82
+
83
+ const handleKeyDown = (e: KeyboardEvent) => {
84
+ switch (e.key) {
85
+ case 'ArrowDown':
86
+ e.preventDefault()
87
+ e.stopPropagation()
88
+ setSelectedIndex((prev) => (prev + 1) % filteredTools.length)
89
+ break
90
+ case 'ArrowUp':
91
+ e.preventDefault()
92
+ e.stopPropagation()
93
+ setSelectedIndex(
94
+ (prev) => (prev - 1 + filteredTools.length) % filteredTools.length
95
+ )
96
+ break
97
+ case 'Enter':
98
+ case 'Tab':
99
+ e.preventDefault()
100
+ e.stopPropagation()
101
+ if (filteredTools[selectedIndex]) {
102
+ selectTool(filteredTools[selectedIndex])
103
+ }
104
+ break
105
+ case 'Escape':
106
+ e.preventDefault()
107
+ e.stopPropagation()
108
+ setIsVisible(false)
109
+ break
110
+ }
111
+ }
112
+
113
+ const textarea = textareaRef.current
114
+ if (textarea) {
115
+ textarea.addEventListener('keydown', handleKeyDown, { capture: true })
116
+ return () =>
117
+ textarea.removeEventListener('keydown', handleKeyDown, {
118
+ capture: true,
119
+ })
120
+ }
121
+ }, [isVisible, filteredTools, selectedIndex, selectTool, textareaRef])
122
+
123
+ // Scroll selected item into view
124
+ useEffect(() => {
125
+ if (!isVisible) return
126
+ const container = containerRef.current
127
+ if (!container) return
128
+
129
+ const selectedItem = container.querySelector(
130
+ `[data-index="${selectedIndex}"]`
131
+ ) as HTMLElement
132
+ if (selectedItem) {
133
+ selectedItem.scrollIntoView({ block: 'nearest' })
134
+ }
135
+ }, [selectedIndex, isVisible])
136
+
137
+ useEffect(() => {
138
+ if (!isVisible) return
139
+
140
+ const handleClickOutside = (e: MouseEvent) => {
141
+ if (
142
+ containerRef.current &&
143
+ !containerRef.current.contains(e.target as Node) &&
144
+ textareaRef.current &&
145
+ !textareaRef.current.contains(e.target as Node)
146
+ ) {
147
+ setIsVisible(false)
148
+ }
149
+ }
150
+
151
+ document.addEventListener('mousedown', handleClickOutside)
152
+ return () => document.removeEventListener('mousedown', handleClickOutside)
153
+ }, [isVisible, textareaRef])
154
+
155
+ // When autocomplete is visible, modify composer styles and position autocomplete to match composer width
156
+ useEffect(() => {
157
+ const textarea = textareaRef.current
158
+ if (!textarea) return
159
+
160
+ const composer = textarea.closest('.aui-composer-root') as HTMLElement
161
+ if (!composer) return
162
+
163
+ const updateStyles = () => {
164
+ const autocomplete = containerRef.current
165
+ if (!autocomplete) return
166
+
167
+ if (isVisible) {
168
+ // Modify composer to connect with autocomplete
169
+ composer.style.borderTopColor = 'var(--ring)'
170
+ composer.style.borderTopLeftRadius = '0'
171
+ composer.style.borderTopRightRadius = '0'
172
+
173
+ // Position autocomplete to match composer width
174
+ const composerRect = composer.getBoundingClientRect()
175
+ const autocompleteParent = autocomplete.offsetParent as HTMLElement
176
+ if (autocompleteParent) {
177
+ const parentRect = autocompleteParent.getBoundingClientRect()
178
+ autocomplete.style.left = `${composerRect.left - parentRect.left}px`
179
+ autocomplete.style.right = 'auto'
180
+ autocomplete.style.width = `${composerRect.width}px`
181
+ }
182
+ }
183
+ }
184
+
185
+ if (isVisible) {
186
+ // Use requestAnimationFrame to ensure DOM is updated
187
+ requestAnimationFrame(updateStyles)
188
+ } else {
189
+ composer.style.borderTopColor = ''
190
+ composer.style.borderTopLeftRadius = ''
191
+ composer.style.borderTopRightRadius = ''
192
+ }
193
+
194
+ return () => {
195
+ composer.style.borderTopColor = ''
196
+ composer.style.borderTopLeftRadius = ''
197
+ composer.style.borderTopRightRadius = ''
198
+ }
199
+ }, [isVisible, textareaRef])
200
+
201
+ if (!isVisible || filteredTools.length === 0) {
202
+ return null
203
+ }
204
+
205
+ return (
206
+ <m.div
207
+ ref={containerRef}
208
+ initial={{ opacity: 0, y: 8 }}
209
+ animate={{ opacity: 1, y: 0 }}
210
+ exit={{ opacity: 0, y: 8 }}
211
+ transition={{ duration: 0.15, ease: EASE_OUT_QUINT }}
212
+ className={cn(
213
+ 'aui-tool-mention-autocomplete border-ring bg-background absolute bottom-full z-50 max-h-[220px] overflow-clip overflow-y-auto overscroll-contain rounded-br-none! rounded-bl-none! border border-b-0 shadow-xs',
214
+ r('xl'),
215
+ className
216
+ )}
217
+ >
218
+ <div className="flex flex-col gap-1">
219
+ {filteredTools.map((tool, index) => (
220
+ <button
221
+ key={tool.id}
222
+ type="button"
223
+ data-index={index}
224
+ className={cn(
225
+ 'aui-tool-mention-item flex w-full items-center gap-2 text-left transition-colors',
226
+ d('p-sm'),
227
+ 'hover:bg-accent hover:text-accent-foreground',
228
+ index === selectedIndex && 'bg-accent text-accent-foreground'
229
+ )}
230
+ onClick={(e) => {
231
+ e.preventDefault()
232
+ e.stopPropagation()
233
+ selectTool(tool)
234
+ }}
235
+ onMouseEnter={() => setSelectedIndex(index)}
236
+ >
237
+ <Wrench className="mt-0.5 size-4 flex-shrink-0 opacity-50" />
238
+ <div className="min-w-0 flex-1">
239
+ <div className="text-sm font-medium">{tool.name}</div>
240
+ {tool.description && (
241
+ <div className="text-muted-foreground line-clamp-2 text-xs">
242
+ {tool.description}
243
+ </div>
244
+ )}
245
+ </div>
246
+ </button>
247
+ ))}
248
+ </div>
249
+ </m.div>
250
+ )
251
+ }
252
+
253
+ export default ToolMentionAutocomplete
@@ -322,7 +322,7 @@ function ToolUISection({
322
322
  <div data-slot="tool-ui-section" className="border-border border-t">
323
323
  <button
324
324
  onClick={() => setIsExpanded(!isExpanded)}
325
- className="hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between px-4 py-2.5 text-left transition-colors"
325
+ className="hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between px-5 py-2.5 text-left transition-colors"
326
326
  >
327
327
  <span className="text-muted-foreground text-sm">{title}</span>
328
328
  <div className="flex items-center gap-1">
@@ -349,8 +349,9 @@ const ElementsProviderWithApproval = ({
349
349
  isOpen: isOpen ?? false,
350
350
  setIsOpen,
351
351
  plugins,
352
+ mcpTools,
352
353
  }),
353
- [config, model, isExpanded, isOpen, plugins]
354
+ [config, model, isExpanded, isOpen, plugins, mcpTools]
354
355
  )
355
356
 
356
357
  const frontendTools = config.tools?.frontendTools ?? {}
@@ -0,0 +1,93 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react'
2
+ import { useAssistantApi, useAssistantState } from '@assistant-ui/react'
3
+ import {
4
+ MentionableTool,
5
+ parseMentionedTools,
6
+ removeToolMention,
7
+ toolSetToMentionableTools,
8
+ } from '@/lib/tool-mentions'
9
+
10
+ export interface UseToolMentionsOptions {
11
+ tools: Record<string, unknown> | undefined
12
+ enabled?: boolean
13
+ }
14
+
15
+ export interface UseToolMentionsReturn {
16
+ mentionableTools: MentionableTool[]
17
+ mentionedToolIds: string[]
18
+ value: string
19
+ cursorPosition: number
20
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>
21
+ updateCursorPosition: () => void
22
+ handleAutocompleteChange: (value: string, cursorPosition: number) => void
23
+ removeMention: (toolId: string) => void
24
+ isActive: boolean
25
+ }
26
+
27
+ export function useToolMentions({
28
+ tools,
29
+ enabled = true,
30
+ }: UseToolMentionsOptions): UseToolMentionsReturn {
31
+ const [cursorPosition, setCursorPosition] = useState(0)
32
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
33
+ const api = useAssistantApi()
34
+ const composerText = useAssistantState(({ composer }) => composer.text)
35
+
36
+ const mentionableTools = useMemo(
37
+ () => toolSetToMentionableTools(tools),
38
+ [tools]
39
+ )
40
+
41
+ const mentionedToolIds = useMemo(
42
+ () => (enabled ? parseMentionedTools(composerText, tools) : []),
43
+ [composerText, tools, enabled]
44
+ )
45
+
46
+ const updateCursorPosition = useCallback(() => {
47
+ const textarea = textareaRef.current
48
+ if (textarea) {
49
+ setCursorPosition(textarea.selectionStart)
50
+ }
51
+ }, [])
52
+
53
+ const handleAutocompleteChange = useCallback(
54
+ (newValue: string, newCursorPosition: number) => {
55
+ api.composer().setText(newValue)
56
+ setCursorPosition(newCursorPosition)
57
+
58
+ setTimeout(() => {
59
+ const textarea = textareaRef.current
60
+ if (textarea) {
61
+ textarea.focus()
62
+ textarea.setSelectionRange(newCursorPosition, newCursorPosition)
63
+ }
64
+ }, 0)
65
+ },
66
+ [api]
67
+ )
68
+
69
+ const removeMention = useCallback(
70
+ (toolId: string) => {
71
+ const tool = mentionableTools.find((t) => t.id === toolId)
72
+ if (tool) {
73
+ const newValue = removeToolMention(composerText, tool.name)
74
+ api.composer().setText(newValue)
75
+ }
76
+ },
77
+ [composerText, mentionableTools, api]
78
+ )
79
+
80
+ const isActive = enabled && mentionableTools.length > 0
81
+
82
+ return {
83
+ mentionableTools,
84
+ mentionedToolIds,
85
+ value: composerText,
86
+ cursorPosition,
87
+ textareaRef,
88
+ updateCursorPosition,
89
+ handleAutocompleteChange,
90
+ removeMention,
91
+ isActive,
92
+ }
93
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  SidecarConfig,
44
44
  Suggestion,
45
45
  ThemeConfig,
46
+ ToolMentionsConfig,
46
47
  ToolsConfig,
47
48
  Variant,
48
49
  VARIANTS,
@@ -0,0 +1,106 @@
1
+ export type ToolRecord = Record<string, unknown> | undefined
2
+
3
+ export interface MentionableTool {
4
+ id: string
5
+ name: string
6
+ description?: string
7
+ }
8
+
9
+ export interface MentionContext {
10
+ isInMention: boolean
11
+ query: string
12
+ atPosition: number
13
+ }
14
+
15
+ const MENTION_PATTERN = /@(\w+)/g
16
+
17
+ export function toolSetToMentionableTools(
18
+ tools: ToolRecord
19
+ ): MentionableTool[] {
20
+ if (!tools) return []
21
+
22
+ return Object.entries(tools).map(([name, tool]) => ({
23
+ id: name,
24
+ name,
25
+ description:
26
+ typeof tool === 'object' && tool !== null && 'description' in tool
27
+ ? String((tool as { description?: unknown }).description ?? '')
28
+ : undefined,
29
+ }))
30
+ }
31
+
32
+ export function parseMentionedTools(text: string, tools: ToolRecord): string[] {
33
+ if (!tools || !text) return []
34
+
35
+ const toolNames = Object.keys(tools)
36
+ const mentions: string[] = []
37
+ let match: RegExpExecArray | null
38
+
39
+ MENTION_PATTERN.lastIndex = 0
40
+ while ((match = MENTION_PATTERN.exec(text)) !== null) {
41
+ mentions.push(match[1].toLowerCase())
42
+ }
43
+
44
+ const matchedToolIds = toolNames.filter((name) =>
45
+ mentions.includes(name.toLowerCase())
46
+ )
47
+
48
+ return [...new Set(matchedToolIds)]
49
+ }
50
+
51
+ export function detectMentionContext(
52
+ text: string,
53
+ cursorPosition: number
54
+ ): MentionContext {
55
+ const textBeforeCursor = text.slice(0, cursorPosition)
56
+ const lastAtSymbol = textBeforeCursor.lastIndexOf('@')
57
+
58
+ if (lastAtSymbol === -1) {
59
+ return { isInMention: false, query: '', atPosition: -1 }
60
+ }
61
+
62
+ const textAfterAt = textBeforeCursor.slice(lastAtSymbol + 1)
63
+
64
+ if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
65
+ return { isInMention: false, query: '', atPosition: -1 }
66
+ }
67
+
68
+ return {
69
+ isInMention: true,
70
+ query: textAfterAt.toLowerCase(),
71
+ atPosition: lastAtSymbol,
72
+ }
73
+ }
74
+
75
+ export function filterToolsByQuery(
76
+ tools: MentionableTool[],
77
+ query: string
78
+ ): MentionableTool[] {
79
+ if (!query) return tools
80
+
81
+ const queryLower = query.toLowerCase()
82
+
83
+ return tools.filter((tool) => {
84
+ const nameMatch = tool.name.toLowerCase().includes(queryLower)
85
+ const descMatch = tool.description?.toLowerCase().includes(queryLower)
86
+ return nameMatch || descMatch
87
+ })
88
+ }
89
+
90
+ export function insertToolMention(
91
+ text: string,
92
+ toolName: string,
93
+ atPosition: number,
94
+ cursorPosition: number
95
+ ): { text: string; cursorPosition: number } {
96
+ const beforeMention = text.slice(0, atPosition)
97
+ const afterCursor = text.slice(cursorPosition)
98
+ const newText = `${beforeMention}@${toolName} ${afterCursor}`
99
+ const newCursorPosition = atPosition + toolName.length + 2
100
+ return { text: newText, cursorPosition: newCursorPosition }
101
+ }
102
+
103
+ export function removeToolMention(text: string, toolName: string): string {
104
+ const pattern = new RegExp(`@${toolName}\\s?`, 'gi')
105
+ return text.replace(pattern, '')
106
+ }
@@ -754,6 +754,13 @@ export interface ComposerConfig {
754
754
  * @default true
755
755
  */
756
756
  attachments?: boolean | AttachmentsConfig
757
+
758
+ /**
759
+ * Configuration for @tool mentions in the composer.
760
+ * Set to `false` to disable, `true` for defaults, or an object for fine-grained control.
761
+ * @default true
762
+ */
763
+ toolMentions?: boolean | ToolMentionsConfig
757
764
  }
758
765
 
759
766
  /**
@@ -781,6 +788,14 @@ export interface AttachmentsConfig {
781
788
  maxSize?: number
782
789
  }
783
790
 
791
+ export interface ToolMentionsConfig {
792
+ /** @default true */
793
+ enabled?: boolean
794
+ /** @default 10 */
795
+ maxSuggestions?: number
796
+ placeholder?: string
797
+ }
798
+
784
799
  export interface SidecarConfig extends ExpandableConfig {
785
800
  /**
786
801
  * The title displayed in the sidecar header.
@@ -829,4 +844,5 @@ export type ElementsContextType = {
829
844
  isOpen: boolean
830
845
  setIsOpen: (isOpen: boolean) => void
831
846
  plugins: Plugin[]
847
+ mcpTools: Record<string, unknown> | undefined
832
848
  }