feat: add collapsed code snippet display in chat input

Implements a feature to show code context as compact chips in the chat
input box rather than displaying full code text. When selecting code and
adding to context, it now appears as a collapsed chip showing the file
path and line range.

Features:
- CodeSnippet type for structured code references
- CodeSnippetChip component for compact display of code references
- Multiple code snippets can be added and displayed as chips
- Full code content is expanded when sending to AI
- Remove button on each chip to remove snippets

Relates to #10085
This commit is contained in:
Roo Code 2025-12-16 09:23:02 +00:00 committed by Hannes Rudolph
parent e3b90fb182
commit 9a05952af7
7 changed files with 221 additions and 8 deletions

View file

@ -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")
}

View file

@ -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"

View file

@ -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

View file

@ -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<

View file

@ -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<React.SetStateAction<string[]>>
codeSnippets?: CodeSnippet[]
onRemoveCodeSnippet?: (id: string) => void
onSend: () => void
onSelectImages: () => void
shouldDisableImages: boolean
@ -65,6 +69,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
placeholderText,
selectedImages,
setSelectedImages,
codeSnippets = [],
onRemoveCodeSnippet,
onSend,
onSelectImages,
shouldDisableImages,
@ -253,10 +259,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
</div>
{codeSnippets.length > 0 && onRemoveCodeSnippet && (
<CodeSnippetChips
snippets={codeSnippets}
onRemove={onRemoveCodeSnippet}
className="bg-vscode-input-background border-b border-vscode-input-border"
/>
)}
{selectedImages.length > 0 && (
<Thumbnails
images={selectedImages}

View file

@ -11,7 +11,8 @@ import { Trans } from "react-i18next"
import { useDebounceEffect } from "@src/utils/useDebounceEffect"
import { appendImages } from "@src/utils/imageUtils"
import type { ClineAsk, ClineMessage } from "@roo-code/types"
import type { ClineAsk, ClineMessage, CodeSnippet } from "@roo-code/types"
import { expandCodeSnippets } from "@roo-code/types"
import { ClineSayTool, ExtensionMessage } from "@roo/ExtensionMessage"
import { findLast } from "@roo/array"
@ -135,6 +136,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [codeSnippets, setCodeSnippets] = useState<CodeSnippet[]>([])
// 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<ChatViewRef, ChatViewPro
setInputValue("")
setSendingDisabled(true)
setSelectedImages([])
setCodeSnippets([])
setClineAsk(undefined)
setEnableButtons(false)
// Do not reset mode here as it should persist.
@ -582,6 +585,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
(text: string, images: string[]) => {
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<ChatViewRef, ChatViewPro
handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length], // messagesRef and clineAskRef are stable
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length, codeSnippets], // messagesRef and clineAskRef are stable
)
const handleSetChatBoxMessage = useCallback(
@ -661,6 +670,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[inputValue, selectedImages],
)
const handleAddCodeSnippet = useCallback((snippet: CodeSnippet) => {
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<ChatViewRef, ChatViewPro
case "secondaryButtonClick":
handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
break
case "addCodeSnippet":
if (message.codeSnippet) {
handleAddCodeSnippet(message.codeSnippet)
}
break
}
break
case "condenseTaskContextStarted":
@ -884,6 +918,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSecondaryButtonClick,
setCheckpointWarning,
playSound,
handleAddCodeSnippet,
],
)
@ -1601,6 +1636,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
placeholderText={placeholderText}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
codeSnippets={codeSnippets}
onRemoveCodeSnippet={handleRemoveCodeSnippet}
onSend={() => handleSendMessage(inputValue, selectedImages)}
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}

View file

@ -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<CodeSnippetChipProps> = ({ 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 (
<StandardTooltip content={tooltipContent}>
<div
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5",
"bg-vscode-badge-background text-vscode-badge-foreground",
"rounded-full text-xs font-medium",
"border border-vscode-contrastBorder",
"max-w-[200px] truncate",
className,
)}>
<FileCode className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{label}</span>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onRemove(snippet.id)
}}
className={cn(
"inline-flex items-center justify-center",
"w-3.5 h-3.5 ml-0.5 flex-shrink-0",
"rounded-full",
"hover:bg-vscode-toolbar-hoverBackground",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"cursor-pointer",
)}
aria-label={t("chat:removeCodeSnippet", { defaultValue: "Remove code snippet" })}>
<X className="w-2.5 h-2.5" />
</button>
</div>
</StandardTooltip>
)
}
interface CodeSnippetChipsProps {
snippets: CodeSnippet[]
onRemove: (id: string) => void
className?: string
}
/**
* Container component for displaying multiple code snippet chips.
*/
export const CodeSnippetChips: React.FC<CodeSnippetChipsProps> = ({ snippets, onRemove, className }) => {
if (snippets.length === 0) {
return null
}
return (
<div className={cn("flex flex-wrap gap-1 px-2 py-1.5", className)}>
{snippets.map((snippet) => (
<CodeSnippetChip key={snippet.id} snippet={snippet} onRemove={onRemove} />
))}
</div>
)
}