Compare commits

..

12 Commits

Author SHA1 Message Date
Saoud Rizwan ee27da2e28 Release Version v3.19.1 2025-07-14 21:49:34 -07:00
Nidelson Gimenez c9b922009f docs: improve documentation (#4863)
* docs: fix typo

* improved clarity in the local development instructions

* Update CONTRIBUTING.md

* Create wise-hairs-grow.md

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-14 21:42:06 -07:00
github-actions[bot] 2d6ff38e69 v3.18.15 Release Notes (#4890)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-14 21:01:12 -07:00
Saoud Rizwan 3069e27413 Add groq to kimi providers (#4906)
* Add groq to kimi providers

* Create fair-glasses-lick.md
2025-07-14 20:54:34 -07:00
Saoud Rizwan 57c8b8120d Revert "pass linter errors with read_file (#4159)" (#4905)
This reverts commit 5e2b199377.
2025-07-14 20:18:19 -07:00
Saoud Rizwan 5243f0b9b1 Set default kimi provider to together (#4904) 2025-07-14 19:49:45 -07:00
celestial-vault c014060275 remove unused ExtensionStateContext setters (#4865) 2025-07-14 18:35:54 -07:00
celestial-vault d790ce86a0 Add markdown parsing to MCP responses (#4862)
* refactor out useEffect logic

* add markdown parsing to mcp response

* add display mode to global state; simplify state flow

* fix imports after merge conflicts
2025-07-14 18:17:14 -07:00
celestial-vault 5e2b199377 pass linter errors with read_file (#4159)
* pass linter errors with read_file

* changeset

* add code back after merge with main
2025-07-14 16:49:37 -07:00
celestial-vault 9234d0cdc4 [McpResponseDisplay] Refactor out useEffect logic to helper function (#4852)
* refactor out useEffect logic

* refactor: memoize renderSegment with useCallback
2025-07-14 15:29:50 -07:00
Sarah Fortune db1db8c95d Change the name of the cline core script from standalone.js to cline-core.js (#4894)
Update the name of the script so that you can tell from the process name what it is,
standalone.js is too vague to be able to associate it with cline.
2025-07-14 15:22:11 -07:00
Sarah Fortune f53af72643 Update the vscode usages script to separate out vscode.commands.executeCommand calls. (#4891)
vscode.commands.executeCommand runs other vscode commands, so we need the know which commands are being run.
2025-07-14 15:21:27 -07:00
25 changed files with 453 additions and 427 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Pass linter diagnostics with the read_file tool
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add Kimi-K2 as the trending model in the Cline Provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Added API Key support for Bedrock integration
+10 -1
View File
@@ -1,5 +1,14 @@
# Changelog
## [3.19.1]
- Fix documentation
## [3.19.0]
- Add Kimi-K2 as a recommended model in the Cline Provider, and route to Together/Groq for 131k context window and high throughput
- Added API Key support for Bedrock integration
## [3.18.14]
- Fix bug where Cline account users logged in with invalid token would not be shown as logged out in webview presentation layer
@@ -40,7 +49,7 @@
## [3.18.6]
- Update request header to include `"ai-client-type": "Cline"` to SAP Api Provider
- Add organization organization accounts
- Add organization accounts
## [3.18.5]
+2 -2
View File
@@ -153,8 +153,8 @@ const extensionConfig = {
// Standalone-specific configuration
const standaloneConfig = {
...baseConfig,
entryPoints: ["src/standalone/standalone.ts"],
outfile: `${destDir}/standalone.js`,
entryPoints: ["src/standalone/cline-core.ts"],
outfile: `${destDir}/cline-core.js`,
// These gRPC protos need to load files from the module directory at runtime,
// so they cannot be bundled.
external: ["vscode", "@grpc/reflection", "grpc-health-check"],
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.18.14",
"version": "3.19.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+1 -1
View File
@@ -112,7 +112,7 @@ message UpdateSettingsRequest {
optional int64 shell_integration_timeout = 8;
optional bool terminal_reuse_enabled = 9;
optional bool mcp_responses_collapsed = 10;
optional bool mcp_rich_display_enabled = 11;
optional string mcp_display_mode = 11;
optional int64 terminal_output_line_limit = 12;
}
+16 -2
View File
@@ -5,17 +5,31 @@ DIR=${1:-src/}
DEST_DIR=dist-standalone
SDK_DEST=$DEST_DIR/vscode-sdk-uses.txt
CSS_DEST=$DEST_DIR/vscode-css-uses.txt
TMP=/tmp/vscode-sdk-uses.txt.tmp
mkdir -p $DEST_DIR
{
git grep -h 'vscode\.' $DIR |
grep -Ev '//.*vscode' | # remove commented out code
grep -v vscode.commands.executeCommand | # executeCommand is handled separately
grep -Ev '"vscode' | # remove command strings that get included because they start with vscode
sed 's|.*vscode\.|vscode.|'| # remove everything before vscode.
sed 's/[^a-zA-Z0-9_.].*$//' | # remove everything after last identifier
grep -E '\.[a-z][^.]+$' | # remove types (last part of identifier should be lowercase)
sort | uniq -c | sort -n | # Count occurrences
cat > $SDK_DEST
cat > $TMP
}
{
grep -rh vscode.commands.executeCommand $DIR |
perl -ne 'print if /["\x27"]/' | # Remove occurrences where the command is not on the same line (line doesnt contain quote chars) :(
sed -n 's|.*\(vscode.commands.executeCommand[^,]*\).*|\1|p'| # Remove all params after the first one
sed 's|\(".*"\).*|\1)|'| # Close the parantheses
cat >> $TMP
}
# Count occurrences
cat $TMP | sort | uniq -c | sort -n > $SDK_DEST
rm $TMP
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
{
+6
View File
@@ -139,6 +139,10 @@ export async function createOpenRouterStream(
shouldApplyMiddleOutTransform = true
}
// hardcoded provider sorting for kimi-k2
const isKimiK2 = model.id.startsWith("moonshotai/kimi-k2")
openRouterProviderSorting = isKimiK2 ? undefined : openRouterProviderSorting
// @ts-ignore-next-line
const stream = await client.chat.completions.create({
model: model.id,
@@ -153,6 +157,8 @@ export async function createOpenRouterStream(
...(model.id.startsWith("openai/o") ? { reasoning_effort: reasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
// limit providers to only those that support the 131k context window
...(isKimiK2 ? { provider: { order: ["groq", "together"], allow_fallbacks: false } } : {}),
})
return stream
+2 -2
View File
@@ -834,7 +834,7 @@ export class Controller {
chatSettings: storedChatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
mcpDisplayMode,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting,
@@ -887,7 +887,7 @@ export class Controller {
chatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
mcpDisplayMode,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
@@ -104,6 +104,12 @@ export async function refreshOpenRouterModels(
modelInfo.cacheWritesPrice = 0.75
modelInfo.cacheReadsPrice = 0
break
case "moonshotai/kimi-k2":
// forcing kimi-k2 to use the together provider for full context and best throughput
modelInfo.inputPrice = 1
modelInfo.outputPrice = 3
modelInfo.contextWindow = 131_000
break
default:
if (rawModel.id.startsWith("openai/")) {
modelInfo.cacheReadsPrice = parsePrice(rawModel.pricing?.input_cache_read)
+3 -3
View File
@@ -50,9 +50,9 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.context.globalState.update("mcpResponsesCollapsed", request.mcpResponsesCollapsed)
}
// Update MCP responses collapsed setting
if (request.mcpRichDisplayEnabled !== undefined) {
await controller.context.globalState.update("mcpRichDisplayEnabled", request.mcpRichDisplayEnabled)
// Update MCP display mode setting
if (request.mcpDisplayMode !== undefined) {
await controller.context.globalState.update("mcpDisplayMode", request.mcpDisplayMode)
}
// Update chat settings
+1 -1
View File
@@ -76,7 +76,7 @@ export type GlobalStateKey =
| "isNewUser"
| "welcomeViewCompleted"
| "terminalOutputLineLimit"
| "mcpRichDisplayEnabled"
| "mcpDisplayMode"
| "sapAiCoreTokenUrl"
| "sapAiCoreBaseUrl"
| "sapAiResourceGroup"
+4 -3
View File
@@ -11,6 +11,7 @@ import { StoredChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@shared/McpDisplayMode"
import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } from "./state-migrations"
/*
Storage
@@ -173,7 +174,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
shellIntegrationTimeout,
enableCheckpointsSettingRaw,
mcpMarketplaceEnabledRaw,
mcpRichDisplayEnabled,
mcpDisplayMode,
mcpResponsesCollapsedRaw,
globalWorkflowToggles,
terminalReuseEnabled,
@@ -248,7 +249,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "shellIntegrationTimeout") as Promise<number | undefined>,
getGlobalState(context, "enableCheckpointsSetting") as Promise<boolean | undefined>,
getGlobalState(context, "mcpMarketplaceEnabled") as Promise<boolean | undefined>,
getGlobalState(context, "mcpRichDisplayEnabled") as Promise<boolean | undefined>,
getGlobalState(context, "mcpDisplayMode") as Promise<McpDisplayMode | undefined>,
getGlobalState(context, "mcpResponsesCollapsed") as Promise<boolean | undefined>,
getGlobalState(context, "globalWorkflowToggles") as Promise<ClineRulesToggles | undefined>,
getGlobalState(context, "terminalReuseEnabled") as Promise<boolean | undefined>,
@@ -470,7 +471,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeAwsBedrockCustomModelBaseId,
previousModeSapAiCoreModelId,
mcpMarketplaceEnabled: mcpMarketplaceEnabled,
mcpRichDisplayEnabled: mcpRichDisplayEnabled ?? true,
mcpDisplayMode: mcpDisplayMode ?? DEFAULT_MCP_DISPLAY_MODE,
mcpResponsesCollapsed: mcpResponsesCollapsed,
telemetrySetting: telemetrySetting || "unset",
planActSeparateModelsSetting,
+1 -20
View File
@@ -41,7 +41,6 @@ import os from "os"
import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { ToolResponse, USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "."
import { ToolParamName, ToolUse, ToolUseName } from "../assistant-message"
import { constructNewFileContent } from "../assistant-message/diff"
@@ -840,28 +839,10 @@ export class ToolExecutor {
// now execute the tool like normal
const content = await extractTextFromFile(absolutePath)
// Get diagnostics for this specific file only
const fileUri = vscode.Uri.file(absolutePath)
const fileDiagnostics = vscode.languages.getDiagnostics(fileUri)
// Format diagnostics if any exist
let diagnosticsMessage = ""
if (fileDiagnostics.length > 0) {
const problemsString = await diagnosticsToProblemsString(
[[fileUri, fileDiagnostics]],
[vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning],
)
diagnosticsMessage = `\n\n---\nNOTE: This file has linter issues. Only address these if they're relevant to your current task:\n${problemsString}\n---`
}
// Combine content with diagnostics
const finalContent = content + diagnosticsMessage
// Track file read operation
await this.fileContextTracker.trackFileContext(relPath, "read_tool")
this.pushToolResult(finalContent, block)
this.pushToolResult(content, block)
await this.saveCheckpoint()
break
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { HistoryItem } from "./HistoryItem"
import { TelemetrySetting } from "./TelemetrySetting"
import { ClineRulesToggles } from "./cline-rules"
import { UserInfo } from "./UserInfo"
import { McpDisplayMode, DEFAULT_MCP_DISPLAY_MODE } from "./McpDisplayMode"
// webview will hold state
export interface ExtensionMessage {
@@ -37,7 +38,7 @@ export interface ExtensionState {
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
mcpMarketplaceEnabled?: boolean
mcpRichDisplayEnabled: boolean
mcpDisplayMode: McpDisplayMode
planActSeparateModelsSetting: boolean
enableCheckpointsSetting?: boolean
platform: Platform
+9
View File
@@ -0,0 +1,9 @@
/**
* Represents the different display modes available for MCP responses
*/
export type McpDisplayMode = "rich" | "plain" | "markdown"
/**
* Default display mode for MCP responses
*/
export const DEFAULT_MCP_DISPLAY_MODE: McpDisplayMode = "plain"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "cline-standalone",
"name": "cline-core",
"version": "0.0.1",
"main": "standalone.js",
"main": "cline-core.js",
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
@@ -0,0 +1,29 @@
import React from "react"
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { McpDisplayMode } from "@shared/McpDisplayMode"
interface McpDisplayModeDropdownProps {
value: McpDisplayMode
onChange: (mode: McpDisplayMode) => void
id?: string
className?: string
style?: React.CSSProperties
onClick?: (e: React.MouseEvent) => void
}
const McpDisplayModeDropdown: React.FC<McpDisplayModeDropdownProps> = ({ value, onChange, id, className, style, onClick }) => {
const handleChange = (e: any) => {
const newMode = e.target.value as McpDisplayMode
onChange(newMode)
}
return (
<VSCodeDropdown id={id} value={value} onChange={handleChange} onClick={onClick} className={className} style={style}>
<VSCodeOption value="plain">Plain Text</VSCodeOption>
<VSCodeOption value="rich">Rich Display</VSCodeOption>
<VSCodeOption value="markdown">Markdown</VSCodeOption>
</VSCodeDropdown>
)
}
export default McpDisplayModeDropdown
@@ -1,12 +1,17 @@
import React, { useEffect, useState, useCallback } from "react"
import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" // Import ProgressRing
import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import LinkPreview from "./LinkPreview"
import ImagePreview from "./ImagePreview"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import ChatErrorBoundary from "@/components/chat/ChatErrorBoundary"
import { isUrl, isLocalhostUrl, formatUrlForOpening, checkIfImageUrl } from "./utils/mcpRichUtil"
import MarkdownBlock from "@/components/common/MarkdownBlock"
import McpDisplayModeDropdown from "./McpDisplayModeDropdown"
import { DropdownContainer } from "@/components/settings/ApiOptions"
import { updateSetting } from "@/components/settings/utils/settingsHandlers"
import { McpDisplayMode } from "@shared/McpDisplayMode"
import { UrlMatch, processResponseUrls, DisplaySegment, buildDisplaySegments } from "./utils/mcpRichUtil"
// Maximum number of URLs to process in total, per response
export const MAX_URLS = 50
@@ -36,46 +41,6 @@ const ResponseHeader = styled.div`
}
`
const ToggleSwitch = styled.div`
display: flex;
align-items: center;
font-size: 12px;
color: var(--vscode-descriptionForeground);
.toggle-label {
margin-right: 8px;
}
.toggle-container {
position: relative;
width: 40px;
height: 20px;
background-color: var(--vscode-button-secondaryBackground);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.3s;
}
.toggle-container.active {
background-color: var(--vscode-button-background);
}
.toggle-handle {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background-color: var(--vscode-button-foreground);
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-container.active .toggle-handle {
transform: translateX(20px);
}
`
const ResponseContainer = styled.div`
position: relative;
font-family: var(--vscode-editor-font-family, monospace);
@@ -107,28 +72,16 @@ interface McpResponseDisplayProps {
responseText: string
}
// Represents a URL found in the text with its position and metadata
interface UrlMatch {
url: string // The actual URL
fullMatch: string // The full matched text
index: number // Position in the text
isImage: boolean // Whether this URL is an image
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
}
const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText }) => {
const { mcpResponsesCollapsed, mcpRichDisplayEnabled } = useExtensionState() // Get setting from context
const { mcpResponsesCollapsed, mcpDisplayMode } = useExtensionState() // Get setting from context
const [isExpanded, setIsExpanded] = useState(!mcpResponsesCollapsed) // Initialize with context setting
const [isLoading, setIsLoading] = useState(false) // Initial loading state for rich content
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
// Initialize directly from the global setting.
return mcpRichDisplayEnabled ? "rich" : "plain"
})
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
const [error, setError] = useState<string | null>(null)
const toggleDisplayMode = useCallback(() => {
setDisplayMode((prevMode) => (prevMode === "rich" ? "plain" : "rich"))
const handleDisplayModeChange = useCallback((newMode: McpDisplayMode) => {
updateSetting("mcpDisplayMode", newMode)
}, [])
const toggleExpand = useCallback(() => {
@@ -142,138 +95,89 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
// Find all URLs in the text and determine if they're images
useEffect(() => {
// Skip all processing if in plain mode
if (!isExpanded || displayMode === "plain") {
// Skip all processing if in plain mode or markdown mode
if (!isExpanded || mcpDisplayMode === "plain" || mcpDisplayMode === "markdown") {
setIsLoading(false)
if (urlMatches.length > 0) {
setUrlMatches([]) // Clear any existing matches when in plain mode
setUrlMatches([]) // Clear any existing matches when not in rich mode
}
return
}
// Use a direct boolean for cancellation that's scoped to this effect run
let processingCanceled = false
const processResponse = async () => {
console.log("Processing MCP response for URL extraction")
setIsLoading(true)
setError(null)
try {
const text = responseText || ""
const matches: UrlMatch[] = []
const urlRegex = /(?:https?:\/\/|data:image)[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
console.log("Processing MCP response for URL extraction")
setIsLoading(true)
setError(null)
// First pass: Extract all URLs and immediately make them available for rendering
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
// Get the original URL from the match - never modify the original URL text
const url = urlMatch[0]
// Skip invalid URLs
if (!isUrl(url)) {
console.log("Skipping invalid URL:", url)
continue
}
// Skip localhost URLs to prevent security issues
if (isLocalhostUrl(url)) {
console.log("Skipping localhost URL:", url)
continue
}
matches.push({
url,
fullMatch: url,
index: urlMatch.index,
isImage: false, // Will check later
isProcessed: false,
})
urlCount++
}
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
// Set matches immediately so UI can start rendering with loading states
setUrlMatches(matches.sort((a, b) => a.index - b.index))
// Mark loading as complete to show content immediately
// Use the orchestrator function from mcpRichUtil
const cleanup = processResponseUrls(
responseText || "",
MAX_URLS,
(matches) => {
setUrlMatches(matches)
setIsLoading(false)
// Process image checks in the background - one at a time to avoid network flooding
const processImageChecks = async () => {
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
for (let i = 0; i < matches.length; i++) {
// Skip already processed URLs (from extension check)
if (matches[i].isProcessed) continue
// Check if processing has been canceled (switched to plain mode)
if (processingCanceled) {
console.log("URL processing canceled - display mode changed to plain")
return
}
const match = matches[i]
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
try {
// Process each URL individually
const isImage = await checkIfImageUrl(match.url)
// Skip if processing has been canceled
if (processingCanceled) return
// Update the match in place
match.isImage = isImage
match.isProcessed = true
// Update state after each URL to show progress
// Create a new array to ensure React detects the state change
setUrlMatches([...matches])
} catch (err) {
console.log(`URL check error: ${match.url}`, err)
match.isProcessed = true
// Update state even on error
if (!processingCanceled) {
setUrlMatches([...matches])
}
}
// Delay between URL processing to avoid overwhelming the network
if (!processingCanceled && i < matches.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
}
// Start the background processing
processImageChecks()
} catch (error) {
setError("Failed to process response content. Switch to plain text mode to view safely.")
},
(updatedMatches) => {
setUrlMatches(updatedMatches)
},
(errorMessage) => {
setError(errorMessage)
setIsLoading(false)
}
}
},
)
processResponse()
return cleanup
}, [responseText, mcpDisplayMode, isExpanded])
// Cleanup function to cancel processing if component unmounts or dependencies change
return () => {
processingCanceled = true
console.log("Cleaning up URL processing")
// Helper function to render a display segment
const renderSegment = (segment: DisplaySegment): JSX.Element => {
switch (segment.type) {
case "text":
case "url":
return <UrlText key={segment.key}>{segment.content}</UrlText>
case "image":
return (
<div key={segment.key}>
<ImagePreview url={segment.url!} />
</div>
)
case "link":
return (
<div key={segment.key} style={{ margin: "10px 0" }}>
<LinkPreview url={segment.url!} />
</div>
)
case "error":
return (
<div
key={segment.key}
style={{
margin: "10px 0",
padding: "8px",
color: "var(--vscode-errorForeground)",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
height: "128px",
overflow: "auto",
}}>
{segment.content}
</div>
)
default:
return <React.Fragment key={segment.key} />
}
}, [responseText, displayMode, isExpanded])
}
// Function to render content based on display mode
const renderContent = () => {
if (!isExpanded) {
return null // Don't render content if not expanded
return null
}
if (isLoading && displayMode === "rich") {
if (isLoading && mcpDisplayMode === "rich") {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "50px" }}>
<VSCodeProgressRing />
@@ -281,12 +185,14 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
)
}
// For plain text mode, just show the text
if (displayMode === "plain") {
if (mcpDisplayMode === "plain") {
return <UrlText>{responseText}</UrlText>
}
// Show error message if there was an error
if (mcpDisplayMode === "markdown") {
return <MarkdownBlock markdown={responseText} />
}
if (error) {
return (
<>
@@ -296,97 +202,9 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
)
}
// For rich display mode, show the text with embedded content
if (displayMode === "rich") {
// We already know displayMode is "rich" if we get here
// Create an array of text segments and embedded content
const segments: JSX.Element[] = []
let lastIndex = 0
let segmentIndex = 0
// Track embed count for logging
let embedCount = 0
// Add the text before the first URL
if (urlMatches.length === 0) {
segments.push(<UrlText key={`segment-${segmentIndex}`}>{responseText}</UrlText>)
} else {
for (let i = 0; i < urlMatches.length; i++) {
const match = urlMatches[i]
const { url, fullMatch, index } = match
// Add text segment before this URL
if (index > lastIndex) {
segments.push(
<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex, index)}</UrlText>,
)
}
// Add the URL text itself
segments.push(<UrlText key={`url-${segmentIndex++}`}>{fullMatch}</UrlText>)
// Calculate the end position of this URL in the text
const urlEndIndex = index + fullMatch.length
// Add embedded content after the URL
// For images, use the ImagePreview component
if (match.isImage) {
segments.push(
<div key={`embed-image-${url}-${segmentIndex++}`}>
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
<ImagePreview url={formatUrlForOpening(url)} />
</div>,
)
embedCount++
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
} else if (match.isProcessed) {
// For non-image URLs or URLs we haven't processed yet, show link preview
try {
// Skip localhost URLs
if (!isLocalhostUrl(url)) {
// Use a unique key that includes the URL to ensure each preview is isolated
segments.push(
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
{/* Already using formatUrlForOpening for link previews */}
<LinkPreview url={formatUrlForOpening(url)} />
</div>,
)
embedCount++
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
}
} catch (e) {
console.log("Link preview could not be created")
// Show error message for failed link preview
segments.push(
<div
key={`embed-error-${segmentIndex++}`}
style={{
margin: "10px 0",
padding: "8px",
color: "var(--vscode-errorForeground)",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
height: "128px", // Fixed height
overflow: "auto", // Allow scrolling if content overflows
}}>
Failed to create preview for: {url}
</div>,
)
}
}
// Update lastIndex for next segment
lastIndex = urlEndIndex
}
// Add any remaining text after the last URL
if (lastIndex < responseText.length) {
segments.push(<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex)}</UrlText>)
}
}
return <>{segments}</>
if (mcpDisplayMode === "rich") {
const segments = buildDisplaySegments(responseText, urlMatches)
return <>{segments.map(renderSegment)}</>
}
return null
@@ -405,16 +223,15 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"} header-icon`}></span>
Response
</div>
<div style={{ minWidth: isExpanded ? "auto" : "0", visibility: isExpanded ? "visible" : "hidden" }}>
<ToggleSwitch onClick={(e) => e.stopPropagation()}>
<span className="toggle-label">{displayMode === "rich" ? "Rich Display" : "Plain Text"}</span>
<div
className={`toggle-container ${displayMode === "rich" ? "active" : ""}`}
onClick={toggleDisplayMode}>
<div className="toggle-handle"></div>
</div>
</ToggleSwitch>
</div>
<DropdownContainer
style={{ minWidth: isExpanded ? "auto" : "0", visibility: isExpanded ? "visible" : "hidden" }}>
<McpDisplayModeDropdown
value={mcpDisplayMode}
onChange={handleDisplayModeChange}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: "120px" }}
/>
</DropdownContainer>
</ResponseHeader>
{isExpanded && <div className="response-content">{renderContent()}</div>}
@@ -1,6 +1,23 @@
import { WebServiceClient } from "@/services/grpc-client"
import { StringRequest } from "@shared/proto/common"
// Represents a URL found in the text with its position and metadata
export interface UrlMatch {
url: string // The actual URL
fullMatch: string // The full matched text
index: number // Position in the text
isImage: boolean // Whether this URL is an image
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
}
// Display segment interface
export interface DisplaySegment {
type: "text" | "url" | "image" | "link" | "error"
content: string
url?: string
key: string // Pre-computed key for React
}
// Safely create a URL object with error handling and ensure HTTPS
export const safeCreateUrl = (url: string): URL | null => {
try {
@@ -168,3 +185,224 @@ export const checkIfImageUrl = async (url: string): Promise<boolean> => {
console.log(`URL protocol not supported for image check: ${url}`)
return false
}
/**
* Extracts all valid URLs from the given text
* @param text - The text to search for URLs
* @param maxUrls - Maximum number of URLs to extract (default: 50)
* @returns Array of URL matches sorted by position in text
*/
export const extractUrlsFromText = (text: string, maxUrls: number = 50): UrlMatch[] => {
const matches: UrlMatch[] = []
const urlRegex = /(?:https?:\/\/|data:image)[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < maxUrls) {
const url = urlMatch[0]
// Skip invalid URLs
if (!isUrl(url)) {
console.log("Skipping invalid URL:", url)
continue
}
// Skip localhost URLs to prevent security issues
if (isLocalhostUrl(url)) {
console.log("Skipping localhost URL:", url)
continue
}
matches.push({
url,
fullMatch: url,
index: urlMatch.index,
isImage: false, // Will be determined later
isProcessed: false,
})
urlCount++
}
console.log(`Found ${matches.length} URLs in text`)
return matches.sort((a, b) => a.index - b.index)
}
/**
* Processes URLs to determine their types (e.g., image vs link)
* Processes URLs sequentially to avoid network flooding
* @param matches - Array of URL matches to process
* @param onProgress - Callback for progress updates with updated matches
* @param cancellationToken - Object to check if processing should be cancelled
* @returns Promise that resolves when processing is complete
*/
export const processUrlTypes = async (
matches: UrlMatch[],
onProgress: (updatedMatches: UrlMatch[]) => void,
cancellationToken: { cancelled: boolean },
): Promise<void> => {
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
for (let i = 0; i < matches.length; i++) {
// Skip already processed URLs
if (matches[i].isProcessed) continue
// Check if processing has been canceled
if (cancellationToken.cancelled) {
console.log("URL processing canceled")
return
}
const match = matches[i]
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
try {
// Check if URL is an image
const isImage = await checkIfImageUrl(match.url)
// Skip if processing has been canceled
if (cancellationToken.cancelled) return
// Update the match
match.isImage = isImage
match.isProcessed = true
// Notify progress with a new array to ensure React detects changes
onProgress([...matches])
} catch (err) {
console.log(`URL check error: ${match.url}`, err)
match.isProcessed = true
// Update state even on error
if (!cancellationToken.cancelled) {
onProgress([...matches])
}
}
// Delay between URL processing to avoid overwhelming the network
if (!cancellationToken.cancelled && i < matches.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
}
/**
* Orchestrates the URL extraction and processing pipeline
* @param text - The response text to process
* @param maxUrls - Maximum number of URLs to process
* @param onMatchesFound - Callback when initial URLs are extracted
* @param onMatchesUpdated - Callback when URL types are determined
* @param onError - Error handler callback
* @returns Cleanup function to cancel processing
*/
export const processResponseUrls = (
text: string,
maxUrls: number,
onMatchesFound: (matches: UrlMatch[]) => void,
onMatchesUpdated: (matches: UrlMatch[]) => void,
onError: (error: string) => void,
): (() => void) => {
const cancellationToken = { cancelled: false }
const process = async () => {
try {
// Extract URLs from text
const matches = extractUrlsFromText(text, maxUrls)
// Immediately notify about found matches
onMatchesFound(matches)
// Process URLs in the background
await processUrlTypes(matches, onMatchesUpdated, cancellationToken)
} catch (error) {
onError("Failed to process response content. Switch to plain text mode to view safely.")
}
}
// Start processing
process()
// Return cleanup function
return () => {
cancellationToken.cancelled = true
console.log("Cleaning up URL processing")
}
}
/**
* Builds an array of display segments from response text and URL matches
* @param responseText - The full response text
* @param urlMatches - Array of URL matches with their positions and types
* @returns Array of display segments describing how to render the content
*/
export const buildDisplaySegments = (responseText: string, urlMatches: UrlMatch[]): DisplaySegment[] => {
const segments: DisplaySegment[] = []
let lastIndex = 0
let segmentIndex = 0
// Handle case with no URLs
if (urlMatches.length === 0) {
return [
{
type: "text",
content: responseText,
key: "segment-0",
},
]
}
// Process each URL match
for (let i = 0; i < urlMatches.length; i++) {
const match = urlMatches[i]
const { url, fullMatch, index } = match
// Add text segment before this URL
if (index > lastIndex) {
segments.push({
type: "text",
content: responseText.substring(lastIndex, index),
key: `segment-${segmentIndex++}`,
})
}
// Add the URL text itself
segments.push({
type: "url",
content: fullMatch,
key: `url-${segmentIndex++}`,
})
// Add embedded content after the URL
if (match.isImage) {
segments.push({
type: "image",
content: url,
url: formatUrlForOpening(url),
key: `embed-image-${url}-${segmentIndex++}`,
})
} else if (match.isProcessed && !isLocalhostUrl(url)) {
segments.push({
type: "link",
content: url,
url: formatUrlForOpening(url),
key: `embed-${url}-${segmentIndex++}`,
})
}
// Update lastIndex for next segment
lastIndex = index + fullMatch.length
}
// Add any remaining text after the last URL
if (lastIndex < responseText.length) {
segments.push({
type: "text",
content: responseText.substring(lastIndex),
key: `segment-${segmentIndex++}`,
})
}
return segments
}
@@ -49,14 +49,14 @@ const featuredModels = [
label: "Best",
},
{
id: "moonshotai/kimi-k2",
description: "Latest open source model, trained for agentic tool calling.",
id: "google/gemini-2.5-pro",
description: "Large 1M context window, great value",
label: "Trending",
},
{
id: "x-ai/grok-4",
description: "Latest flagship model from xAI",
label: "Fast & Cheap",
id: "moonshotai/kimi-k2",
description: "Open source model topping coding benchmarks",
label: "New",
},
]
@@ -4,6 +4,8 @@ import { memo } from "react"
import { OpenAIReasoningEffort } from "@shared/ChatSettings"
import { updateSetting } from "../utils/settingsHandlers"
import { convertChatSettingsToProtoChatSettings } from "@shared/proto-conversions/state/chat-settings-conversion"
import { McpDisplayMode } from "@shared/McpDisplayMode"
import McpDisplayModeDropdown from "@/components/mcp/chat-display/McpDisplayModeDropdown"
import Section from "../Section"
interface FeatureSettingsSectionProps {
@@ -11,7 +13,7 @@ interface FeatureSettingsSectionProps {
}
const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionProps) => {
const { enableCheckpointsSetting, mcpMarketplaceEnabled, mcpRichDisplayEnabled, mcpResponsesCollapsed, chatSettings } =
const { enableCheckpointsSetting, mcpMarketplaceEnabled, mcpDisplayMode, mcpResponsesCollapsed, chatSettings } =
useExtensionState()
const handleReasoningEffortChange = (newValue: OpenAIReasoningEffort) => {
@@ -59,16 +61,20 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</p>
</div>
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={mcpRichDisplayEnabled}
onChange={(e: any) => {
const checked = e.target.checked === true
updateSetting("mcpRichDisplayEnabled", checked)
}}>
Enable Rich MCP Display
</VSCodeCheckbox>
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
Enables rich formatting for MCP responses. When disabled, responses will be shown in plain text.
<label
htmlFor="mcp-display-mode-dropdown"
className="block text-sm font-medium text-[var(--vscode-foreground)] mb-1">
MCP Display Mode
</label>
<McpDisplayModeDropdown
id="mcp-display-mode-dropdown"
value={mcpDisplayMode}
onChange={(newMode: McpDisplayMode) => updateSetting("mcpDisplayMode", newMode)}
className="w-full"
/>
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
Controls how MCP responses are displayed: plain text, rich formatting with links/images, or markdown
rendering.
</p>
</div>
<div style={{ marginTop: 10 }}>
@@ -14,13 +14,11 @@ import { TerminalProfile } from "@shared/proto/state"
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
import { convertProtoMcpServersToMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS, BrowserSettings } from "@shared/BrowserSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "@shared/ChatSettings"
import { DEFAULT_PLATFORM, ExtensionMessage, ExtensionState } from "@shared/ExtensionMessage"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { DEFAULT_PLATFORM, ExtensionState } from "@shared/ExtensionMessage"
import { findLastIndex } from "@shared/array"
import {
ApiConfiguration,
ModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
@@ -31,6 +29,7 @@ import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/share
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/models"
import { UserInfo } from "@shared/proto/account"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -54,19 +53,8 @@ interface ExtensionStateContextType extends ExtensionState {
showAnnouncement: boolean
// Setters
setApiConfiguration: (config: ApiConfiguration) => void
setTelemetrySetting: (value: TelemetrySetting) => void
setShowAnnouncement: (value: boolean) => void
setShouldShowAnnouncement: (value: boolean) => void
setPlanActSeparateModelsSetting: (value: boolean) => void
setEnableCheckpointsSetting: (value: boolean) => void
setMcpMarketplaceEnabled: (value: boolean) => void
setMcpRichDisplayEnabled: (value: boolean) => void
setMcpResponsesCollapsed: (value: boolean) => void
setShellIntegrationTimeout: (value: number) => void
setTerminalReuseEnabled: (value: boolean) => void
setTerminalOutputLineLimit: (value: number) => void
setDefaultTerminalProfile: (value: string) => void
setChatSettings: (value: ChatSettings) => void
setMcpServers: (value: McpServer[]) => void
setRequestyModels: (value: Record<string, ModelInfo>) => void
@@ -78,8 +66,6 @@ interface ExtensionStateContextType extends ExtensionState {
setGlobalWorkflowToggles: (toggles: Record<string, boolean>) => void
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
setTotalTasksSize: (value: number | null) => void
setAvailableTerminalProfiles: (profiles: TerminalProfile[]) => void // Setter for profiles
setBrowserSettings: (value: BrowserSettings) => void
// Refresh functions
refreshOpenRouterModels: () => void
@@ -190,7 +176,7 @@ export const ExtensionStateContextProvider: React.FC<{
distinctId: "",
planActSeparateModelsSetting: true,
enableCheckpointsSetting: true,
mcpRichDisplayEnabled: true,
mcpDisplayMode: DEFAULT_MCP_DISPLAY_MODE,
globalClineRulesToggles: {},
localClineRulesToggles: {},
localCursorRulesToggles: {},
@@ -676,72 +662,15 @@ export const ExtensionStateContextProvider: React.FC<{
hideHistory,
hideAccount,
hideAnnouncement,
setApiConfiguration: (value) =>
setState((prevState) => ({
...prevState,
apiConfiguration: value,
})),
setTelemetrySetting: (value) =>
setState((prevState) => ({
...prevState,
telemetrySetting: value,
})),
setPlanActSeparateModelsSetting: (value) =>
setState((prevState) => ({
...prevState,
planActSeparateModelsSetting: value,
})),
setEnableCheckpointsSetting: (value) =>
setState((prevState) => ({
...prevState,
enableCheckpointsSetting: value,
})),
setMcpMarketplaceEnabled: (value) =>
setState((prevState) => ({
...prevState,
mcpMarketplaceEnabled: value,
})),
setMcpRichDisplayEnabled: (value) =>
setState((prevState) => ({
...prevState,
mcpRichDisplayEnabled: value,
})),
setMcpResponsesCollapsed: (value) => {
setState((prevState) => ({
...prevState,
mcpResponsesCollapsed: value,
}))
},
setShowAnnouncement,
setShouldShowAnnouncement: (value) =>
setState((prevState) => ({
...prevState,
shouldShowAnnouncement: value,
})),
setShellIntegrationTimeout: (value) =>
setState((prevState) => ({
...prevState,
shellIntegrationTimeout: value,
})),
setTerminalReuseEnabled: (value) =>
setState((prevState) => ({
...prevState,
terminalReuseEnabled: value,
})),
setTerminalOutputLineLimit: (value) =>
setState((prevState) => ({
...prevState,
terminalOutputLineLimit: value,
})),
setDefaultTerminalProfile: (value) =>
setState((prevState) => ({
...prevState,
defaultTerminalProfile: value,
})),
setMcpServers: (mcpServers: McpServer[]) => setMcpServers(mcpServers),
setRequestyModels: (models: Record<string, ModelInfo>) => setRequestyModels(models),
setMcpMarketplaceCatalog: (catalog: McpMarketplaceCatalog) => setMcpMarketplaceCatalog(catalog),
setAvailableTerminalProfiles,
setShowMcp,
closeMcpView,
setChatSettings: async (value) => {
@@ -768,7 +697,7 @@ export const ExtensionStateContextProvider: React.FC<{
planActSeparateModelsSetting: state.planActSeparateModelsSetting,
enableCheckpointsSetting: state.enableCheckpointsSetting,
mcpMarketplaceEnabled: state.mcpMarketplaceEnabled,
mcpRichDisplayEnabled: state.mcpRichDisplayEnabled,
mcpDisplayMode: state.mcpDisplayMode,
mcpResponsesCollapsed: state.mcpResponsesCollapsed,
}),
)
@@ -811,11 +740,6 @@ export const ExtensionStateContextProvider: React.FC<{
refreshOpenRouterModels,
onRelinquishControl,
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
setBrowserSettings: (value: BrowserSettings) =>
setState((prevState) => ({
...prevState,
browserSettings: value,
})),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>