From 21f02e045ab1f17da6bc4a88e8afcd03314032b2 Mon Sep 17 00:00:00 2001 From: Sma1lboy <541898146chen@gmail.com> Date: Tue, 11 Mar 2025 13:53:08 -0500 Subject: [PATCH 1/2] feat(chat): add command handling and regex support for commands in chat --- ee/tabby-ui/components/chat/chat-context.ts | 2 + ee/tabby-ui/components/chat/chat.tsx | 3 +- .../components/chat/form-editor/command.tsx | 255 ++++++++++++++++++ ee/tabby-ui/components/chat/prompt-form.tsx | 121 ++++++++- .../components/message-markdown/index.tsx | 35 +++ ee/tabby-ui/lib/constants/regex.ts | 4 + ee/tabby-ui/lib/utils/chat.ts | 14 + 7 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 ee/tabby-ui/components/chat/form-editor/command.tsx diff --git a/ee/tabby-ui/components/chat/chat-context.ts b/ee/tabby-ui/components/chat/chat-context.ts index 6983f83548e3..10971326e2cb 100644 --- a/ee/tabby-ui/components/chat/chat-context.ts +++ b/ee/tabby-ui/components/chat/chat-context.ts @@ -1,5 +1,6 @@ import { createContext, RefObject } from 'react' import type { + ChatCommand, FileLocation, FileRange, ListFileItem, @@ -53,6 +54,7 @@ export type ChatContextValue = { setSelectedRepoId: React.Dispatch> repos: RepositorySourceListQuery['repositoryList'] | undefined fetchingRepos: boolean + executeCommand: (command: ChatCommand) => Promise } export const ChatContext = createContext( diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index e9be8cb68b3c..b80a860243f3 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -784,7 +784,8 @@ export const Chat = React.forwardRef( initialized, listFileInWorkspace, readFileContent, - listSymbols + listSymbols, + executeCommand }} >
diff --git a/ee/tabby-ui/components/chat/form-editor/command.tsx b/ee/tabby-ui/components/chat/form-editor/command.tsx new file mode 100644 index 000000000000..a946f96b631b --- /dev/null +++ b/ee/tabby-ui/components/chat/form-editor/command.tsx @@ -0,0 +1,255 @@ +import React, { + forwardRef, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import Mention from '@tiptap/extension-mention' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion' +import { Loader2 } from 'lucide-react' +import { ChatCommand } from 'tabby-chat-panel' + +import { useDebounceValue } from '@/lib/hooks/use-debounce' +import { cn } from '@/lib/utils' + +/** + * A React component to render a command node in the editor. + * Displays the command name and an icon in a highlighted style. + */ +export const CommandComponent = ({ node }: { node: any }) => { + const { label } = node.attrs + + return ( + + + /{label} + + + ) +} + +/** + * A custom TipTap extension to handle slash commands (like /command). + * We extend Mention but set a unique name to avoid key conflicts. + */ +export const PromptFormCommandExtension = Mention.extend({ + // Set a unique name for this extension to avoid key conflicts with the mention extension + name: 'slashCommand', + + // Uses ReactNodeViewRenderer for custom node rendering + addNodeView() { + return ReactNodeViewRenderer(CommandComponent) + }, + + renderText({ node }) { + return `[[command:${node.attrs.label}]]` + }, + addAttributes() { + return { + id: { + default: null, + parseHTML: element => element.getAttribute('data-command-id'), + renderHTML: attrs => { + if (!attrs.commandId) return {} + return { 'data-command-id': attrs.commandId } + } + }, + commandId: { + default: null, + parseHTML: element => element.getAttribute('data-command-id'), + renderHTML: attrs => { + if (!attrs.commandId) return {} + return { 'data-command-id': attrs.commandId } + } + }, + label: { + default: '', + parseHTML: element => element.getAttribute('data-label'), + renderHTML: attrs => { + if (!attrs.label) return {} + return { 'data-label': attrs.label } + } + } + } + } +}) + +export interface CommandListActions { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +// TODO: use predefined command +export interface CommandItem { + id: string + name: string + description?: string + icon?: React.ReactNode +} + +export interface CommandListProps extends SuggestionProps { + items: CommandItem[] + onSelectCommand: (command: ChatCommand) => void +} + +/** + * A React component for the command dropdown list. + * Displays when a user types '/...' and shows available commands. + */ +export const CommandList = forwardRef( + ({ items: propItems, command, query }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [debouncedIsLoading] = useDebounceValue(isLoading, 100) + + // Filter items based on query + const items = useMemo(() => { + if (!query) return propItems + return propItems.filter(item => + item.name.toLowerCase().includes(query.toLowerCase()) + ) + }, [propItems, query]) + + const handleSelect = (item: CommandItem) => { + command({ + commandId: item.id, + label: item.name + }) + } + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (isLoading) { + return false + } + const lastIndex = items.length - 1 + let newIndex = selectedIndex + + switch (event.key) { + case 'ArrowUp': + newIndex = Math.max(0, selectedIndex - 1) + break + case 'ArrowDown': + newIndex = Math.min(lastIndex, selectedIndex + 1) + break + case 'Enter': + if (items[selectedIndex]) { + handleSelect(items[selectedIndex]) + } + return true + default: + return false + } + + setSelectedIndex(newIndex) + return true + } + })) + + return ( +
+ {debouncedIsLoading && ( +
+ +
+ )} + +
+ {items.length === 0 ? ( +
+ No commands found +
+ ) : ( +
+ {items.map((item, index) => ( + handleSelect(item)} + onMouseEnter={() => setSelectedIndex(index)} + title={item.name} + isSelected={index === selectedIndex} + data={item} + /> + ))} +
+ )} +
+
+ ) + } +) +CommandList.displayName = 'CommandList' + +interface CommandItemViewProps extends React.HTMLAttributes { + isSelected: boolean + data: CommandItem +} + +function CommandItemView({ isSelected, data, ...rest }: CommandItemViewProps) { + const ref = useRef(null) + + useLayoutEffect(() => { + if (isSelected && ref.current) { + ref.current?.scrollIntoView({ + block: 'nearest', + inline: 'nearest' + }) + } + }, [isSelected]) + + return ( +
+ /{data.name} + {data.description && ( + + {data.description} + + )} +
+ ) +} + +// TODO: using chat command +export const availableCommands: CommandItem[] = [ + { + id: 'explain', + name: 'explain', + description: 'Explain the selected code' + }, + { + id: 'refactor', + name: 'refactor', + description: 'Refactor the selected code' + }, + { + id: 'test', + name: 'test', + description: 'Generate tests for the selected code' + }, + { + id: 'docs', + name: 'docs', + description: 'Generate documentation for the selected code' + }, + { + id: 'fix', + name: 'fix', + description: 'Fix issues in the selected code' + } +] diff --git a/ee/tabby-ui/components/chat/prompt-form.tsx b/ee/tabby-ui/components/chat/prompt-form.tsx index 333e562be89b..842db2598bcb 100644 --- a/ee/tabby-ui/components/chat/prompt-form.tsx +++ b/ee/tabby-ui/components/chat/prompt-form.tsx @@ -15,8 +15,10 @@ import { import './prompt-form.css' -import { EditorState } from '@tiptap/pm/state' +import { EditorState, PluginKey } from '@tiptap/pm/state' import { isEqual, uniqBy } from 'lodash-es' +import { Slash } from 'lucide-react' +import { ChatCommand } from 'tabby-chat-panel/index' import tippy, { GetReferenceClientRect, Instance } from 'tippy.js' import { NEWLINE_CHARACTER } from '@/lib/constants' @@ -30,6 +32,13 @@ import { IconArrowRight, IconAtSign } from '@/components/ui/icons' import { ModelSelect } from '../textarea-search/model-select' import { ChatContext } from './chat-context' +import { + availableCommands, + CommandList, + CommandListActions, + CommandListProps, + PromptFormCommandExtension +} from './form-editor/command' import { MentionList, MentionListActions, @@ -53,7 +62,8 @@ const PromptForm = React.forwardRef( readFileContent, relevantContext, setRelevantContext, - listSymbols + listSymbols, + executeCommand } = useContext(ChatContext) const { selectedModel, models } = useSelectedModel() @@ -179,6 +189,81 @@ const PromptForm = React.forwardRef( } } } + }), + + PromptFormCommandExtension.configure({ + deleteTriggerWithBackspace: true, + suggestion: { + char: '/', + pluginKey: new PluginKey('command'), + items: ({ query }) => { + return availableCommands.filter( + item => + !query || + item.name.toLowerCase().includes(query.toLowerCase()) + ) + }, + render: () => { + let component: ReactRenderer< + CommandListActions, + CommandListProps + > + let popup: Instance[] + + return { + onStart: props => { + component = new ReactRenderer(CommandList, { + props: { + ...props + }, + editor: props.editor + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: + props.clientRect as GetReferenceClientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'top-start', + animation: 'shift-away' + }) + }, + onUpdate: props => { + component.updateProps(props) + }, + onExit: () => { + popup[0].destroy() + component.destroy() + }, + onKeyDown: props => { + if (props.event.key === 'Escape') { + popup[0].hide() + return true + } + return component.ref?.onKeyDown(props) ?? false + } + } + }, + allow: ({ state }) => { + let hasCommand = false + + state.doc.descendants(node => { + if (node.type.name === 'slashCommand') { + hasCommand = true + return false + } + }) + + return !hasCommand + } + } }) ], editorProps: { @@ -253,6 +338,28 @@ const PromptForm = React.forwardRef( editor?.chain().focus().run() }) } + const onInsertCommand = (prefix: string) => { + if (!editor) return + + editor + .chain() + .focus() + .command(({ tr, state }) => { + const { $from } = state.selection + const isAtLineStart = $from.parentOffset === 0 + const isPrecededBySpace = + $from.nodeBefore?.text?.endsWith(' ') ?? false + + if (isAtLineStart || isPrecededBySpace) { + tr.insertText(prefix) + } else { + tr.insertText(' ' + prefix) + } + + return true + }) + .run() + } /** * Expose methods to the parent component via ref @@ -352,7 +459,7 @@ const PromptForm = React.forwardRef(
-
+
{!!listFileInWorkspace && ( )} + + { + const fullMatch = match[1] + return { + encodedSymbol: fullMatch + } + }) addTextNode(text.slice(lastIndex)) @@ -508,6 +517,32 @@ function SymbolTag({ ) } +function CommandTag({ + encodedSymbol, + className +}: { + encodedSymbol: string | undefined + className?: string +}) { + const symbol = useMemo(() => { + if (!encodedSymbol) return null + try { + const decodedSymbol = decodeURIComponent(encodedSymbol) + return decodedSymbol as string + } catch (e) { + return null + } + }, [encodedSymbol]) + + if (!symbol) return null + + return ( + + {getPromptForChatCommand(symbol as unknown as ChatCommand)} + + ) +} + function RelevantDocumentBadge({ relevantDocument, citationIndex diff --git a/ee/tabby-ui/lib/constants/regex.ts b/ee/tabby-ui/lib/constants/regex.ts index 8248057a22dd..160e98ff52a4 100644 --- a/ee/tabby-ui/lib/constants/regex.ts +++ b/ee/tabby-ui/lib/constants/regex.ts @@ -9,3 +9,7 @@ export const MARKDOWN_FILE_REGEX = /\[\[file:([^\]]+)\]\]/g export const PLACEHOLDER_SYMBOL_REGEX = /\[\[symbol:({.*?})\]\]/g export const MARKDOWN_SYMBOL_REGEX = /\[\[symbol:([^\]]+)\]\]/g + +export const COMMAND_REGEX = /\[\[command:([^\]]+)\]\]/g + +export const PLACEHOLDER_COMMAND_REGEX = /\[\[command:({.*?})\]\]/g diff --git a/ee/tabby-ui/lib/utils/chat.ts b/ee/tabby-ui/lib/utils/chat.ts index 648beb816f16..54d829f2ee80 100644 --- a/ee/tabby-ui/lib/utils/chat.ts +++ b/ee/tabby-ui/lib/utils/chat.ts @@ -10,6 +10,7 @@ import { import type { MentionAttributes } from '@/lib/types' import { + COMMAND_REGEX, MARKDOWN_FILE_REGEX, MARKDOWN_SOURCE_REGEX, PLACEHOLDER_FILE_REGEX, @@ -213,6 +214,19 @@ export function encodeMentionPlaceHolder(value: string): string { } } + // also dealing with command + while ((match = COMMAND_REGEX.exec(value)) !== null) { + try { + // eslint-disable-next-line no-console + console.log('machine command', match[0]) + newValue = newValue.replace( + match[0], + `[[command:${encodeURIComponent(match[1])}]]` + ) + } catch (error) { + continue + } + } return newValue } From a898ce69ee00f3c83a9e34fb7c69bb5157e45cfc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 18:54:55 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- ee/tabby-ui/components/chat/prompt-form.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ee/tabby-ui/components/chat/prompt-form.tsx b/ee/tabby-ui/components/chat/prompt-form.tsx index 842db2598bcb..e15fa341a57a 100644 --- a/ee/tabby-ui/components/chat/prompt-form.tsx +++ b/ee/tabby-ui/components/chat/prompt-form.tsx @@ -18,7 +18,6 @@ import './prompt-form.css' import { EditorState, PluginKey } from '@tiptap/pm/state' import { isEqual, uniqBy } from 'lodash-es' import { Slash } from 'lucide-react' -import { ChatCommand } from 'tabby-chat-panel/index' import tippy, { GetReferenceClientRect, Instance } from 'tippy.js' import { NEWLINE_CHARACTER } from '@/lib/constants'