Compare commits

...

22 Commits

Author SHA1 Message Date
0xtoshii ad636b0fb9 changeset 2025-04-18 19:27:55 -07:00
0xtoshii da36622f99 xml 2025-04-18 19:26:35 -07:00
0xtoshii ba0117de45 format 2025-04-18 13:14:40 -07:00
0xtoshii e2426e5735 css styles 2025-04-18 12:47:57 -07:00
0xtoshii b3e5588bce changeset 2025-04-17 16:51:45 -07:00
0xtoshii 85076ed39e rm 2025-04-17 16:38:06 -07:00
0xtoshii faf2361dbe formatting new call 2025-04-17 16:37:05 -07:00
Toshii d4c35a6877 Merge branch 'main' into ft/sl-commands 2025-04-17 16:30:36 -07:00
0xtoshii 93aa8e03a7 styles 2025-04-17 16:11:34 -07:00
0xtoshii 5fe9b3cb8b highlighting boxes 2025-04-17 15:57:45 -07:00
0xtoshii e557ea5e26 spacing 2025-04-17 12:31:45 -07:00
0xtoshii 718b826b19 color 2025-04-16 19:04:37 -07:00
0xtoshii 6b97490f9d cursor position 2025-04-16 18:56:35 -07:00
0xtoshii 6b1314be24 consider cursor 2025-04-16 18:18:31 -07:00
0xtoshii 6facb3e2e6 menu wrap 2025-04-16 17:00:24 -07:00
0xtoshii bc481d0c7f nits 2025-04-16 15:51:11 -07:00
0xtoshii 12fde2f05a highlights 2025-04-16 13:06:49 -07:00
0xtoshii d9272352e4 menu base 2025-04-16 12:08:03 -07:00
0xtoshii a2811c60c9 new model 2025-04-14 17:55:04 -07:00
0xtoshii e4c42a5dbf test base 2025-04-14 16:20:07 -07:00
0xtoshii 5e0a0deb82 format 2025-04-14 16:19:56 -07:00
0xtoshii e699103a2f base 2025-04-14 16:10:47 -07:00
12 changed files with 486 additions and 16 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
new slash command menu, slash command to trigger new_task
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
structured xml
+43
View File
@@ -0,0 +1,43 @@
export const newTaskToolResponse = () =>
`<explicit_instructions type="new_task">
The user has explicitly asked you to help them create a new task with preloaded context, which you will create. In this message the user has potentially added instructions or context which you should consider, if given, when creating the new task.
Irrespective of whether additional information or instructions are given, you are only allowed to respond to this message by calling the new_task tool.
To refresh your memory, the tool definition for new_task and an example for calling the tool is described below:
## new_task tool definition:
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation. The user may choose to start a new task at any point.
Parameters:
- context: (required) The context to preload the new task with. This should include:
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
* The specific next steps or focus for the new task - mention specific file names that are relevant
* Any critical information needed to continue the work
* Clear indication of how this new task relates to the overall workflow
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
Usage:
<new_task>
<context>context to preload new task with</context>
</new_task>
## Tool use example:
<new_task>
<context>
Authentication System Implementation:
- We've implemented the basic user model with email/password
- Password hashing is working with bcrypt
- Login endpoint is functional with proper validation
- JWT token generation is implemented
Next Steps:
- Implement refresh token functionality
- Add token validation middleware
- Create password reset flow
- Implement role-based access control
</context>
</new_task>
Below is the the user's input when they indicated that they wanted to create a new task.
</explicit_instructions>\n
`
+55
View File
@@ -0,0 +1,55 @@
import { newTaskToolResponse } from "../prompts/commands"
/**
* Processes text for slash commands and transforms them with appropriate instructions
* This is called after parseMentions() to process any slash commands in the user's message
*/
export function parseSlashCommands(text: string): string {
const SUPPORTED_COMMANDS = ["newtask"]
const commandReplacements: Record<string, string> = {
newtask: newTaskToolResponse(),
}
// this currently allows matching prepended whitespace prior to /slash-command
const tagPatterns = [
{ tag: "task", regex: /<task>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/task>/is },
{ tag: "feedback", regex: /<feedback>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/feedback>/is },
{ tag: "answer", regex: /<answer>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/answer>/is },
{ tag: "user_message", regex: /<user_message>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/user_message>/is },
]
// if we find a valid match, we will return inside that block
for (const { tag, regex } of tagPatterns) {
const regexObj = new RegExp(regex.source, regex.flags)
const match = regexObj.exec(text)
if (match) {
// match[1] is the command with any leading whitespace (e.g. " /newtask")
// match[2] is just the command name (e.g. "newtask")
const commandName = match[2] // casing matters
if (SUPPORTED_COMMANDS.includes(commandName)) {
const fullMatchStartIndex = match.index
// find position of slash command within the full match
const fullMatch = match[0]
const relativeStartIndex = fullMatch.indexOf(match[1])
// calculate absolute indices in the original string
const slashCommandStartIndex = fullMatchStartIndex + relativeStartIndex
const slashCommandEndIndex = slashCommandStartIndex + match[1].length
// remove the slash command and add custom instructions at the top of this message
const textWithoutSlashCommand = text.substring(0, slashCommandStartIndex) + text.substring(slashCommandEndIndex)
const processedText = commandReplacements[commandName] + textWithoutSlashCommand
return processedText
}
}
}
// if no supported commands are found, return the original text
return text
}
+5 -6
View File
@@ -86,6 +86,7 @@ import {
refreshClineRulesToggles,
} from "../context/instructions/user-instructions/cline-rules"
import { getGlobalState } from "../storage/state"
import { parseSlashCommands } from ".././slash-commands"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
@@ -3550,12 +3551,10 @@ export class Task {
block.text.includes("<task>") ||
block.text.includes("<user_message>")
) {
const parsedText = await parseMentions(
block.text,
cwd,
this.urlContentFetcher,
this.fileContextTracker,
)
let parsedText = await parseMentions(block.text, cwd, this.urlContentFetcher, this.fileContextTracker)
// when parsing slash commands, we still want to allow the user to provide their desired context
parsedText = parseSlashCommands(parsedText)
return {
...block,
+1 -1
View File
@@ -27,7 +27,7 @@ import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import { highlightText } from "./TaskHeader"
import SuccessButton from "@/components/common/SuccessButton"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import NewTaskPreview from "./NewTaskPreview"
+124 -3
View File
@@ -15,6 +15,13 @@ import {
shouldShowContextMenu,
SearchResult,
} from "@/utils/context-mentions"
import {
SlashCommand,
shouldShowSlashCommandsMenu,
getMatchingSlashCommands,
insertSlashCommand,
validateSlashCommand,
} from "@/utils/slash-commands"
import { useMetaKeyDetection, useShortcut } from "@/utils/hooks"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { vscode } from "@/utils/vscode"
@@ -24,6 +31,7 @@ import Tooltip from "@/components/common/Tooltip"
import ApiOptions, { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { MAX_IMAGES_PER_MESSAGE } from "@/components/chat/ChatView"
import ContextMenu from "@/components/chat/ContextMenu"
import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
import { ChatSettings } from "@shared/ChatSettings"
import ServersToggleModal from "./ServersToggleModal"
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
@@ -229,6 +237,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
const [showSlashCommandsMenu, setShowSlashCommandsMenu] = useState(false)
const [selectedSlashCommandsIndex, setSelectedSlashCommandsIndex] = useState(0)
const [slashCommandsQuery, setSlashCommandsQuery] = useState("")
const slashCommandsMenuContainerRef = useRef<HTMLDivElement>(null)
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
@@ -389,8 +402,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[setInputValue, cursorPosition],
)
const handleSlashCommandsSelect = useCallback(
(command: SlashCommand) => {
setShowSlashCommandsMenu(false)
if (textAreaRef.current) {
const { newValue, commandIndex } = insertSlashCommand(textAreaRef.current.value, command.name)
const newCursorPosition = newValue.indexOf(" ", commandIndex + 1 + command.name.length) + 1
setInputValue(newValue)
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.blur()
textAreaRef.current.focus()
}
}, 0)
}
},
[setInputValue],
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashCommandsMenu) {
if (event.key === "Escape") {
setShowSlashCommandsMenu(false)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
setSelectedSlashCommandsIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
const commands = getMatchingSlashCommands(slashCommandsQuery)
if (commands.length === 0) {
return prevIndex
}
const newIndex = (prevIndex + direction + commands.length) % commands.length
return newIndex
})
return
}
if ((event.key === "Enter" || event.key === "Tab") && selectedSlashCommandsIndex !== -1) {
event.preventDefault()
const commands = getMatchingSlashCommands(slashCommandsQuery)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
}
return
}
}
if (showContextMenu) {
if (event.key === "Escape") {
// event.preventDefault()
@@ -542,9 +609,28 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = e.target.selectionStart
setInputValue(newValue)
setCursorPosition(newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
let showMenu = shouldShowContextMenu(newValue, newCursorPosition)
const showSlashCommandsMenu = shouldShowSlashCommandsMenu(newValue, newCursorPosition)
// we do not allow both menus to be shown at the same time
// the slash commands menu has precedence bc its a narrower component
if (showSlashCommandsMenu) {
showMenu = false
}
setShowSlashCommandsMenu(showSlashCommandsMenu)
setShowContextMenu(showMenu)
if (showSlashCommandsMenu) {
const slashIndex = newValue.indexOf("/")
const query = newValue.slice(slashIndex + 1, newCursorPosition)
setSlashCommandsQuery(query)
setSelectedSlashCommandsIndex(0)
} else {
setSlashCommandsQuery("")
setSelectedSlashCommandsIndex(0)
}
if (showMenu) {
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
@@ -591,6 +677,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Only hide the context menu if the user didn't click on it
if (!isMouseDownOnMenu) {
setShowContextMenu(false)
setShowSlashCommandsMenu(false)
}
setIsTextAreaFocused(false)
}, [isMouseDownOnMenu])
@@ -682,13 +769,35 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const updateHighlights = useCallback(() => {
if (!textAreaRef.current || !highlightLayerRef.current) return
const text = textAreaRef.current.value
let processedText = textAreaRef.current.value
highlightLayerRef.current.innerHTML = text
processedText = processedText
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
// highlight @mentions
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
// check for highlighting /slash-commands
if (/^\s*\//.test(processedText)) {
const slashIndex = processedText.indexOf("/")
// end of command is end of text or first whitespace
const spaceIndex = processedText.indexOf(" ", slashIndex)
const endIndex = spaceIndex > -1 ? spaceIndex : processedText.length
// extract and validate the exact command text
const commandText = processedText.substring(slashIndex + 1, endIndex)
const isValidCommand = validateSlashCommand(commandText)
if (isValidCommand) {
const fullCommand = processedText.substring(slashIndex, endIndex) // includes slash
const highlighted = `<mark class="slash-command-match-textarea-highlight">${fullCommand}</mark>`
processedText = processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex)
}
}
highlightLayerRef.current.innerHTML = processedText
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [])
@@ -1012,6 +1121,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}}
onDrop={onDrop}
onDragOver={onDragOver}>
{showSlashCommandsMenu && (
<div ref={slashCommandsMenuContainerRef}>
<SlashCommandMenu
onSelect={handleSlashCommandsSelect}
selectedIndex={selectedSlashCommandsIndex}
setSelectedIndex={setSelectedSlashCommandsIndex}
onMouseDown={handleMenuMouseDown}
query={slashCommandsQuery}
/>
</div>
)}
{showContextMenu && (
<div ref={contextMenuContainerRef}>
<ContextMenu
@@ -0,0 +1,80 @@
import React, { useCallback, useRef, useEffect } from "react"
import { SlashCommand, getMatchingSlashCommands } from "@/utils/slash-commands"
interface SlashCommandMenuProps {
onSelect: (command: SlashCommand) => void
selectedIndex: number
setSelectedIndex: (index: number) => void
onMouseDown: () => void
query: string
}
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedIndex, setSelectedIndex, onMouseDown, query }) => {
const menuRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(
(command: SlashCommand) => {
onSelect(command)
},
[onSelect],
)
// Auto-scroll logic remains the same...
useEffect(() => {
if (menuRef.current) {
const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement
if (selectedElement) {
const menuRect = menuRef.current.getBoundingClientRect()
const selectedRect = selectedElement.getBoundingClientRect()
if (selectedRect.bottom > menuRect.bottom) {
menuRef.current.scrollTop += selectedRect.bottom - menuRect.bottom
} else if (selectedRect.top < menuRect.top) {
menuRef.current.scrollTop -= menuRect.top - selectedRect.top
}
}
}
}, [selectedIndex])
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(query)
return (
<div
className="absolute bottom-[calc(100%-10px)] left-[15px] right-[15px] overflow-x-hidden z-[1000]"
onMouseDown={onMouseDown}>
<div
ref={menuRef}
className="bg-[var(--vscode-dropdown-background)] border border-[var(--vscode-editorGroup-border)] rounded-[3px] shadow-[0_4px_10px_rgba(0,0,0,0.25)] flex flex-col max-h-[200px] overflow-y-auto" // Corrected rounded and shadow
>
{filteredCommands.length > 0 ? (
filteredCommands.map((command, index) => (
<div
key={command.name}
className={`py-2 px-3 cursor-pointer flex flex-col border-b border-[var(--vscode-editorGroup-border)] ${
// Corrected padding
index === selectedIndex
? "bg-[var(--vscode-quickInputList-focusBackground)] text-[var(--vscode-quickInputList-focusForeground)]"
: "" // Removed bg-transparent
} hover:bg-[var(--vscode-list-hoverBackground)]`}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(index)}>
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">/{command.name}</div>
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)] whitespace-normal overflow-hidden text-ellipsis">
{command.description}
</div>
</div>
))
) : (
<div className="py-2 px-3 cursor-default flex flex-col">
{" "}
{/* Corrected padding, removed border, changed cursor */}
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)]">No matching commands found</div>
</div>
)}
</div>
</div>
)
}
export default SlashCommandMenu
+61 -4
View File
@@ -9,6 +9,7 @@ import { formatSize } from "@/utils/format"
import { vscode } from "@/utils/vscode"
import Thumbnails from "@/components/common/Thumbnails"
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { validateSlashCommand } from "@/utils/slash-commands"
interface TaskHeaderProps {
task: ClineMessage
@@ -254,7 +255,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
Task
{!isTaskExpanded && ":"}
</span>
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>}
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightText(task.text, false)}</span>}
</div>
</div>
{!isTaskExpanded && isCostAvailable && (
@@ -300,7 +301,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{highlightMentions(task.text, false)}
{highlightText(task.text, false)}
</div>
{!isTextExpanded && showSeeMore && (
<div
@@ -554,9 +555,41 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)
}
export const highlightMentions = (text?: string, withShadow = true) => {
if (!text) return text
/**
* Highlights slash-command in this text if it exists
*/
const highlightSlashCommands = (text: string, withShadow = true) => {
const match = text.match(/^\s*\/([a-zA-Z0-9_-]+)(\s*|$)/)
if (!match) {
return text
}
const commandName = match[1]
const validationResult = validateSlashCommand(commandName)
if (!validationResult || validationResult !== "full") {
return text
}
const commandEndIndex = match[0].length
const beforeCommand = text.substring(0, text.indexOf("/"))
const afterCommand = match[2] + text.substring(commandEndIndex)
return [
beforeCommand,
<span key="slashCommand" className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}>
/{commandName}
</span>,
afterCommand,
]
}
/**
* Highlights & formats all mentions inside this text
*/
export const highlightMentions = (text: string, withShadow = true) => {
const parts = text.split(mentionRegexGlobal)
return parts.map((part, index) => {
if (index % 2 === 0) {
// This is regular text
@@ -576,6 +609,30 @@ export const highlightMentions = (text?: string, withShadow = true) => {
})
}
/**
* Handles parsing both mentions and slash-commands
*/
export const highlightText = (text?: string, withShadow = true) => {
if (!text) {
return text
}
const resultWithSlashHighlighting = highlightSlashCommands(text, withShadow)
if (resultWithSlashHighlighting === text) {
// no highlighting done
return highlightMentions(resultWithSlashHighlighting, withShadow)
}
if (Array.isArray(resultWithSlashHighlighting) && resultWithSlashHighlighting.length === 3) {
const [beforeCommand, commandElement, afterCommand] = resultWithSlashHighlighting as [string, JSX.Element, string]
return [beforeCommand, commandElement, ...highlightMentions(afterCommand, withShadow)]
}
return [text]
}
const DeleteButton: React.FC<{
taskSize: string
taskId?: string
@@ -1,6 +1,6 @@
import React, { useState, useRef, forwardRef, useCallback } from "react"
import Thumbnails from "@/components/common/Thumbnails"
import { highlightMentions } from "./TaskHeader"
import { highlightText } from "./TaskHeader"
import { vscode } from "@/utils/vscode"
import DynamicTextArea from "react-textarea-autosize"
import { useExtensionState } from "@/context/ExtensionStateContext"
@@ -137,7 +137,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, messageTs, send
</div>
</>
) : (
<span style={{ display: "block" }}>{highlightMentions(editedText || text)}</span>
<span style={{ display: "block" }}>{highlightText(editedText || text)}</span>
)}
{images && images.length > 0 && <Thumbnails images={images} style={{ marginTop: "8px" }} />}
</div>
+7
View File
@@ -173,3 +173,10 @@ vscode-dropdown::part(listbox) {
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent);
}
.slash-command-match-textarea-highlight {
background-color: color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
color: transparent;
}
+98
View File
@@ -0,0 +1,98 @@
export interface SlashCommand {
name: string
description: string
}
export const SUPPORTED_SLASH_COMMANDS: SlashCommand[] = [
{
name: "newtask",
description: "Create a new task with context from the current task",
},
]
// Regex for detecting slash commands in text
export const slashCommandRegex = /\/([a-zA-Z0-9_-]+)(\s|$)/
export const slashCommandRegexGlobal = new RegExp(slashCommandRegex.source, "g")
/**
* Determines whether the slash command menu should be displayed based on text input
*/
export function shouldShowSlashCommandsMenu(text: string, cursorPosition: number): boolean {
const beforeCursor = text.slice(0, cursorPosition)
// first check if there is a slash before the cursor
const slashIndex = beforeCursor.lastIndexOf("/")
if (slashIndex === -1) {
return false
}
// check if slash is at the very beginning (with optional whitespace)
const textBeforeSlash = beforeCursor.slice(0, slashIndex)
if (!/^\s*$/.test(textBeforeSlash)) {
return false
}
// potential partial or full command
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
// don't show menu if there's whitespace after the slash but before the cursor
if (/\s/.test(textAfterSlash)) {
return false
}
return true
}
/**
* Gets filtered slash commands that match the current input
*/
export function getMatchingSlashCommands(query: string): SlashCommand[] {
if (!query) {
return [...SUPPORTED_SLASH_COMMANDS]
}
// filter commands that start with the query (case sensitive)
return SUPPORTED_SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(query))
}
/**
* Insert a slash command at position or replace partial command
*/
export function insertSlashCommand(text: string, commandName: string): { newValue: string; commandIndex: number } {
const slashIndex = text.indexOf("/")
// where the command ends, at the end of entire text or first space
const commandEndIndex = text.indexOf(" ", slashIndex)
// replace the partial command with the full command
const newValue =
text.substring(0, slashIndex + 1) + commandName + (commandEndIndex > -1 ? text.substring(commandEndIndex) : " ") // add extra space at the end if only slash command
return { newValue, commandIndex: slashIndex }
}
/**
* Determines the validation state of a slash command
* Returns partial if we have a partial match against valid commands, or full for full match
*/
export function validateSlashCommand(command: string): "full" | "partial" | null {
if (!command) {
return null
}
// case sensitive matching
const exactMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name === command)
if (exactMatch) {
return "full"
}
const partialMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name.startsWith(command))
if (partialMatch) {
return "partial"
}
return null // no match
}