mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad636b0fb9 | |||
| da36622f99 | |||
| ba0117de45 | |||
| e2426e5735 | |||
| b3e5588bce | |||
| 85076ed39e | |||
| faf2361dbe | |||
| d4c35a6877 | |||
| 93aa8e03a7 | |||
| 5fe9b3cb8b | |||
| e557ea5e26 | |||
| 718b826b19 | |||
| 6b97490f9d | |||
| 6b1314be24 | |||
| 6facb3e2e6 | |||
| bc481d0c7f | |||
| 12fde2f05a | |||
| d9272352e4 | |||
| a2811c60c9 | |||
| e4c42a5dbf | |||
| 5e0a0deb82 | |||
| e699103a2f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
new slash command menu, slash command to trigger new_task
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
structured xml
|
||||
@@ -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
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => ({ "<": "<", ">": ">", "&": "&" })[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
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user