diff --git a/packages/types/src/code-snippet.ts b/packages/types/src/code-snippet.ts new file mode 100644 index 0000000000..1bb4dd07b2 --- /dev/null +++ b/packages/types/src/code-snippet.ts @@ -0,0 +1,51 @@ +/** + * Represents a code snippet reference that can be added to the chat input. + * The snippet is displayed in a collapsed/compressed form in the UI, + * but the full code content is sent to the AI. + */ +export interface CodeSnippet { + /** Unique identifier for the snippet */ + id: string + /** File path relative to workspace */ + filePath: string + /** Start line number (1-indexed) */ + startLine: number + /** End line number (1-indexed) */ + endLine: number + /** The actual code content */ + content: string + /** Timestamp when the snippet was added */ + timestamp: number +} + +/** + * Creates a unique ID for a code snippet + */ +export function createCodeSnippetId(): string { + return `snippet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` +} + +/** + * Formats a code snippet for display in a collapsed chip/pill format + */ +export function formatCodeSnippetLabel(snippet: CodeSnippet): string { + const fileName = snippet.filePath.split("/").pop() || snippet.filePath + return `${fileName}:${snippet.startLine}-${snippet.endLine}` +} + +/** + * Expands a code snippet into the full text format to be sent to the AI + */ +export function expandCodeSnippet(snippet: CodeSnippet): string { + return `${snippet.filePath}:${snippet.startLine}-${snippet.endLine} +\`\`\` +${snippet.content} +\`\`\`` +} + +/** + * Expands multiple code snippets and joins them with spacing + */ +export function expandCodeSnippets(snippets: CodeSnippet[]): string { + return snippets.map(expandCodeSnippet).join("\n\n") +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c4e5088d63..439e176d33 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,6 @@ export * from "./api.js" export * from "./cloud.js" +export * from "./code-snippet.js" export * from "./codebase-index.js" export * from "./context-management.js" export * from "./cookie-consent.js" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6153af6160..793fd85858 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -34,6 +34,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type CodeSnippet, RooCodeEventName, requestyDefaultModelId, openRouterDefaultModelId, @@ -43,6 +44,7 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, + createCodeSnippetId, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" @@ -682,10 +684,19 @@ export class ClineProvider const prompt = supportPrompt.create(promptType, params, customSupportPrompts) if (command === "addToContext") { + // Create a code snippet for collapsed display in the input + const codeSnippet: CodeSnippet = { + id: createCodeSnippetId(), + filePath: params.filePath as string, + startLine: params.startLine as unknown as number, + endLine: params.endLine as unknown as number, + content: params.selectedText as string, + timestamp: Date.now(), + } await visibleProvider.postMessageToWebview({ type: "invoke", - invoke: "setChatBoxMessage", - text: `${prompt}\n\n`, + invoke: "addCodeSnippet", + codeSnippet, }) await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" }) return diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c8..6936d0204e 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -15,6 +15,7 @@ import type { ShareVisibility, QueuedMessage, SerializedCustomToolDefinition, + CodeSnippet, } from "@roo-code/types" import { GitCommit } from "../utils/git" @@ -149,7 +150,13 @@ export interface ExtensionMessage { | "focusInput" | "switchTab" | "toggleAutoApprove" - invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" + invoke?: + | "newChat" + | "sendMessage" + | "primaryButtonClick" + | "secondaryButtonClick" + | "setChatBoxMessage" + | "addCodeSnippet" state?: ExtensionState images?: string[] filePaths?: string[] @@ -218,6 +225,7 @@ export interface ExtensionMessage { isBrowserSessionActive?: boolean // For browser session panel updates stepIndex?: number // For browserSessionNavigate: the target step index to display tools?: SerializedCustomToolDefinition[] // For customToolsResult + codeSnippet?: CodeSnippet // For addCodeSnippet: the code snippet to add } export type ExtensionState = Pick< diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 7fbc658718..fd82586eb8 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -7,6 +7,7 @@ import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } import { WebviewMessage } from "@roo/WebviewMessage" import { Mode, getAllModes } from "@roo/modes" import { ExtensionMessage } from "@roo/ExtensionMessage" +import type { CodeSnippet } from "@roo-code/types" import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -32,6 +33,7 @@ import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" import { usePromptHistory } from "./hooks/usePromptHistory" import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher" +import { CodeSnippetChips } from "./CodeSnippetChip" interface ChatTextAreaProps { inputValue: string @@ -41,6 +43,8 @@ interface ChatTextAreaProps { placeholderText: string selectedImages: string[] setSelectedImages: React.Dispatch> + codeSnippets?: CodeSnippet[] + onRemoveCodeSnippet?: (id: string) => void onSend: () => void onSelectImages: () => void shouldDisableImages: boolean @@ -65,6 +69,8 @@ export const ChatTextArea = forwardRef( placeholderText, selectedImages, setSelectedImages, + codeSnippets = [], + onRemoveCodeSnippet, onSend, onSelectImages, shouldDisableImages, @@ -253,10 +259,10 @@ export const ChatTextArea = forwardRef( const allModes = useMemo(() => getAllModes(customModes), [customModes]) - // Memoized check for whether the input has content (text or images) + // Memoized check for whether the input has content (text, images, or code snippets) const hasInputContent = useMemo(() => { - return inputValue.trim().length > 0 || selectedImages.length > 0 - }, [inputValue, selectedImages]) + return inputValue.trim().length > 0 || selectedImages.length > 0 || codeSnippets.length > 0 + }, [inputValue, selectedImages, codeSnippets]) // Compute the key combination text for the send button tooltip based on enterBehavior const sendKeyCombination = useMemo(() => { @@ -1229,6 +1235,14 @@ export const ChatTextArea = forwardRef( + {codeSnippets.length > 0 && onRemoveCodeSnippet && ( + + )} + {selectedImages.length > 0 && ( (null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [codeSnippets, setCodeSnippets] = useState([]) // We need to hold on to the ask because useEffect > lastMessage will always // let us know when an ask comes in and handle it, but by the time @@ -566,6 +568,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { text = text.trim() + // Expand code snippets and prepend to the message + if (codeSnippets.length > 0) { + const expandedSnippets = expandCodeSnippets(codeSnippets) + text = expandedSnippets + (text ? "\n\n" + text : "") + } + if (text || images.length > 0) { // Queue message if: // - Task is busy (sendingDisabled) @@ -643,7 +652,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + setCodeSnippets((prev) => { + // Avoid duplicates by checking if snippet with same file/lines already exists + const isDuplicate = prev.some( + (s) => + s.filePath === snippet.filePath && + s.startLine === snippet.startLine && + s.endLine === snippet.endLine, + ) + if (isDuplicate) { + return prev + } + return [...prev, snippet] + }) + }, []) + + const handleRemoveCodeSnippet = useCallback((id: string) => { + setCodeSnippets((prev) => prev.filter((s) => s.id !== id)) + }, []) + const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), []) // This logic depends on the useEffect[messages] above to set clineAsk, @@ -838,6 +867,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleSendMessage(inputValue, selectedImages)} onSelectImages={selectImages} shouldDisableImages={shouldDisableImages} diff --git a/webview-ui/src/components/chat/CodeSnippetChip.tsx b/webview-ui/src/components/chat/CodeSnippetChip.tsx new file mode 100644 index 0000000000..08f7c58742 --- /dev/null +++ b/webview-ui/src/components/chat/CodeSnippetChip.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from "react" +import { X, FileCode } from "lucide-react" + +import type { CodeSnippet } from "@roo-code/types" +import { formatCodeSnippetLabel } from "@roo-code/types" + +import { cn } from "@src/lib/utils" +import { StandardTooltip } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" + +interface CodeSnippetChipProps { + snippet: CodeSnippet + onRemove: (id: string) => void + className?: string +} + +/** + * A compact chip component that displays a collapsed code snippet reference. + * Shows the file name and line range in a pill format. + * Users can click the X to remove the snippet. + */ +export const CodeSnippetChip: React.FC = ({ snippet, onRemove, className }) => { + const { t } = useAppTranslation() + + const label = useMemo(() => formatCodeSnippetLabel(snippet), [snippet]) + + const tooltipContent = useMemo(() => { + const lineCount = snippet.endLine - snippet.startLine + 1 + return t("chat:codeSnippetTooltip", { + lineCount, + defaultValue: `${lineCount} line${lineCount > 1 ? "s" : ""} of code`, + }) + }, [snippet, t]) + + return ( + +
+ + {label} + +
+
+ ) +} + +interface CodeSnippetChipsProps { + snippets: CodeSnippet[] + onRemove: (id: string) => void + className?: string +} + +/** + * Container component for displaying multiple code snippet chips. + */ +export const CodeSnippetChips: React.FC = ({ snippets, onRemove, className }) => { + if (snippets.length === 0) { + return null + } + + return ( +
+ {snippets.map((snippet) => ( + + ))} +
+ ) +}