Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/types/src/__tests__/code-snippet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest"
import {
CodeSnippet,
createCodeSnippetId,
formatCodeSnippetLabel,
expandCodeSnippet,
expandCodeSnippets,
} from "../code-snippet.js"

describe("code-snippet", () => {
describe("createCodeSnippetId", () => {
it("should create unique IDs", () => {
const id1 = createCodeSnippetId()
const id2 = createCodeSnippetId()
expect(id1).not.toBe(id2)
})

it("should start with 'snippet-' prefix", () => {
const id = createCodeSnippetId()
expect(id).toMatch(/^snippet-/)
})
})

describe("formatCodeSnippetLabel", () => {
it("should format label with only line numbers", () => {
const snippet: CodeSnippet = {
id: "test-id",
filePath: "src/components/Button.tsx",
startLine: 10,
endLine: 25,
content: "const Button = () => {}",
timestamp: Date.now(),
}
expect(formatCodeSnippetLabel(snippet)).toBe("lines 10-25")
})

it("should handle single line snippet", () => {
const snippet: CodeSnippet = {
id: "test-id",
filePath: "index.ts",
startLine: 1,
endLine: 1,
content: "export default {}",
timestamp: Date.now(),
}
expect(formatCodeSnippetLabel(snippet)).toBe("lines 1-1")
})
})

describe("expandCodeSnippet", () => {
it("should expand snippet to full format with file path", () => {
const snippet: CodeSnippet = {
id: "test-id",
filePath: "src/utils.ts",
startLine: 5,
endLine: 10,
content: "function helper() {\n return true;\n}",
timestamp: Date.now(),
}
const result = expandCodeSnippet(snippet)
expect(result).toContain("src/utils.ts:5-10")
expect(result).toContain("```")
expect(result).toContain("function helper()")
})
})

describe("expandCodeSnippets", () => {
it("should expand multiple snippets with spacing", () => {
const snippets: CodeSnippet[] = [
{
id: "test-1",
filePath: "file1.ts",
startLine: 1,
endLine: 5,
content: "const a = 1",
timestamp: Date.now(),
},
{
id: "test-2",
filePath: "file2.ts",
startLine: 10,
endLine: 15,
content: "const b = 2",
timestamp: Date.now(),
},
]
const result = expandCodeSnippets(snippets)
expect(result).toContain("file1.ts:1-5")
expect(result).toContain("file2.ts:10-15")
expect(result).toContain("\n\n")
})

it("should return empty string for empty array", () => {
expect(expandCodeSnippets([])).toBe("")
})
})
})
51 changes: 51 additions & 0 deletions packages/types/src/code-snippet.ts
Original file line number Diff line number Diff line change
@@ -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).substring(2, 11)}`
}

/**
* Formats a code snippet for display in a collapsed chip/pill format.
* Shows only line numbers since the code is always from the current page.
*/
export function formatCodeSnippetLabel(snippet: CodeSnippet): string {
return `lines ${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")
}
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 13 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type CreateTaskOptions,
type TokenUsage,
type ToolUsage,
type CodeSnippet,
RooCodeEventName,
requestyDefaultModelId,
openRouterDefaultModelId,
Expand All @@ -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"
Expand Down Expand Up @@ -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: Number(params.startLine),
endLine: Number(params.endLine),
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
Expand Down
10 changes: 9 additions & 1 deletion src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ShareVisibility,
QueuedMessage,
SerializedCustomToolDefinition,
CodeSnippet,
} from "@roo-code/types"

import { GitCommit } from "../utils/git"
Expand Down Expand Up @@ -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[]
Expand Down Expand Up @@ -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<
Expand Down
20 changes: 17 additions & 3 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -65,6 +69,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
placeholderText,
selectedImages,
setSelectedImages,
codeSnippets = [],
onRemoveCodeSnippet,
onSend,
onSelectImages,
shouldDisableImages,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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}
Expand Down
41 changes: 39 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -884,6 +918,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSecondaryButtonClick,
setCheckpointWarning,
playSound,
handleAddCodeSnippet,
],
)

Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading