mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46540b092 | ||
|
|
775294d58c | ||
|
|
6c7a9e1097 | ||
|
|
6fcbf36999 | ||
|
|
fa4eb2d51d | ||
|
|
cd934a4909 | ||
|
|
974731d76b | ||
|
|
33019c9fbd | ||
|
|
d585174faa | ||
|
|
c2c3354108 | ||
|
|
5445693f6a | ||
|
|
3c713f41c1 | ||
|
|
2820ac86b5 | ||
|
|
3c97233dcf | ||
|
|
f547e66ed0 | ||
|
|
d1af056793 | ||
|
|
6d1820ec85 | ||
|
|
c21e28020e | ||
|
|
716518f049 | ||
|
|
31601a177c | ||
|
|
c493432bfe | ||
|
|
764b870d7a | ||
|
|
4eb0955446 | ||
|
|
00183e1779 | ||
|
|
c246c7a41c | ||
|
|
0e87fa7894 | ||
|
|
5192d2118f | ||
|
|
e2163e5a06 | ||
|
|
3f049b5f6b | ||
|
|
4c256b86dd | ||
|
|
9cec5a4ba3 | ||
|
|
debdce40b6 | ||
|
|
036b27443a | ||
|
|
86e557564f | ||
|
|
fe582695b8 | ||
|
|
617f6b4be7 | ||
|
|
309f19e0a3 | ||
|
|
e599209f91 | ||
|
|
6065aece2e | ||
|
|
9080c90b7b | ||
|
|
61327126ab | ||
|
|
9c135dd813 | ||
|
|
b779513f6c | ||
|
|
e6c4344bfb | ||
|
|
074bc9b791 | ||
|
|
2409a1a624 | ||
|
|
44c5982994 | ||
|
|
5240755949 | ||
|
|
29790f5fff | ||
|
|
f09bb4a8a2 | ||
|
|
67ecc05376 | ||
|
|
09b8d3dbc4 | ||
|
|
1698386b4c | ||
|
|
27a8957a06 | ||
|
|
5fbd549858 | ||
|
|
aed4c5655c | ||
|
|
594392acbc | ||
|
|
be90866307 |
@@ -16,9 +16,6 @@ e2e.describe("Diff Editor", () => {
|
||||
await sidebar.getByTestId("send-button").click()
|
||||
await expect(inputbox).toHaveValue("")
|
||||
|
||||
// Loading State initially
|
||||
await expect(sidebar.getByText("API Request...")).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Back to home page with history
|
||||
await sidebar.getByRole("button", { name: "Start New Task" }).click()
|
||||
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
|
||||
|
||||
@@ -511,7 +511,8 @@ const createErrorMessages = () => [
|
||||
"text",
|
||||
"I can see there are TypeScript errors in your code. Let me examine the files and fix these issues.",
|
||||
),
|
||||
createMessage(3.3, "say", "tool", JSON.stringify({ tool: "readFile", path: "src/components/UserProfile.tsx" })),
|
||||
createMessage(3.3, "say", "tool", JSON.stringify({ tool: "readFile", path: "src/components/UserProfile_1.tsx" })),
|
||||
createMessage(3.3, "say", "tool", JSON.stringify({ tool: "readFile", path: "src/components/UserProfile_2.tsx" })),
|
||||
createMessage(
|
||||
3,
|
||||
"say",
|
||||
@@ -569,7 +570,7 @@ const createPlanModeMessages = () => [
|
||||
createApiReqMessage(4.5, "Detailed planning request", { tokensIn: 20002, tokensOut: 12500, cost: 0.095 }),
|
||||
createAskMessage(
|
||||
"plan_mode_respond",
|
||||
"Here's my comprehensive plan for refactoring your React application with TypeScript migration and performance optimization phases.",
|
||||
"Here's my comprehensive plan for refactoring your React application with TypeScript migration and performance optimization phases.\n\n\n\n\nPhase 1: TypeScript Migration\n1. Set up TypeScript in the project\n2. Rename .js files to .tsx/.ts\n3. Add type definitions for components and props\n4. Fix type errors and ensure type safety\n\nPhase 2: Performance Optimization\n1. Analyze current performance bottlenecks\n2. Implement code-splitting and lazy loading\n3. Optimize rendering with React.memo and useCallback\n4. Minimize bundle size with tree-shaking and minification\n5. Test performance improvements using profiling tools",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -630,7 +631,7 @@ export const BrowserAutomation: Story = {
|
||||
const createToolApprovalMessages = () => [
|
||||
createMessage(5, "say", "task", "Help me read the configuration file"),
|
||||
createMessage(4.7, "say", "text", "I need to read a file to understand your configuration."),
|
||||
createAskMessage("tool", JSON.stringify({ tool: "read_file", path: "config.json" })),
|
||||
createAskMessage("tool", JSON.stringify({ tool: "readFile", path: "config.json" })),
|
||||
]
|
||||
|
||||
export const ToolApproval: Story = {
|
||||
@@ -676,6 +677,7 @@ const quickStory = (
|
||||
clineMessages: [
|
||||
...createLongMessages(),
|
||||
createMessage(6, "say", "task", `Help with ${name.toLowerCase()}`),
|
||||
createMessage(5, "say", "reasoning", `Thinking about helping user with ${name.toLowerCase()}`),
|
||||
createMessage(4.7, "say", "text", `I'll help you with ${name.toLowerCase()}.`),
|
||||
createAskMessage(askType, text, streamingFailedMessage),
|
||||
],
|
||||
@@ -725,7 +727,7 @@ export const MistakeLimitReached = quickStory(
|
||||
export const CompletionResult = quickStory(
|
||||
"Task Completion",
|
||||
"completion_result",
|
||||
"Task completed successfully! I've implemented all the requested features.",
|
||||
"Task completed successfully! I've implemented all the requested features.\n\nWould you like to start a new task?\n\n- View Changes\n- Start New Task\n- Resume Previous Task HAS_CHANGES",
|
||||
"Shows task completion state with Start New Task button.",
|
||||
)
|
||||
export const BrowserActionLaunch = quickStory(
|
||||
@@ -772,7 +774,7 @@ export const ApiRequestActive: Story = {
|
||||
export const PlanModeResponse = quickStory(
|
||||
"Plan Mode Response",
|
||||
"plan_mode_respond",
|
||||
"Here's my detailed plan for creating a comprehensive testing strategy.",
|
||||
"Here's my comprehensive plan for refactoring your React application with TypeScript migration and performance optimization phases.\n\n\n\n\nPhase 1: TypeScript Migration\n1. Set up TypeScript in the project\n2. Rename .js files to .tsx/.ts\n3. Add type definitions for components and props\n4. Fix type errors and ensure type safety\n\nPhase 2: Performance Optimization\n1. Analyze current performance bottlenecks\n2. Implement code-splitting and lazy loading\n3. Optimize rendering with React.memo and useCallback\n4. Minimize bundle size with tree-shaking and minification\n5. Test performance improvements using profiling tools",
|
||||
"Shows plan mode response where Cline presents a detailed plan for user approval.",
|
||||
)
|
||||
export const CondenseConversation = quickStory(
|
||||
@@ -784,7 +786,10 @@ export const CondenseConversation = quickStory(
|
||||
export const ReportBug = quickStory(
|
||||
"Report Bug",
|
||||
"report_bug",
|
||||
"Would you like to report this issue to help improve Cline?",
|
||||
JSON.stringify({
|
||||
steps_to_reproduce: "1. Open Cline\n2. Start a new task\n3. Observe the error",
|
||||
what_happened: "Cline crashes unexpectedly",
|
||||
}),
|
||||
"Shows utility action to report bugs to the GitHub repository.",
|
||||
)
|
||||
export const ResumeCompletedTask = quickStory(
|
||||
@@ -794,6 +799,231 @@ export const ResumeCompletedTask = quickStory(
|
||||
"Shows Start New Task option for resume completed task.",
|
||||
)
|
||||
|
||||
export const ShellIntegrationWarningWithSuggestion: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Run a command"),
|
||||
createMessage(4.7, "say", "text", "I'll run the command for you."),
|
||||
createMessage(4.5, "say", "shell_integration_warning_with_suggestion", ""),
|
||||
],
|
||||
vscodeTerminalExecutionMode: "integrated",
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows shell integration warning with suggestion to enable Background Terminal mode.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ShellIntegrationWarningBackgroundEnabled: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Run a command"),
|
||||
createMessage(4.7, "say", "text", "I'll run the command for you."),
|
||||
createMessage(4.5, "say", "shell_integration_warning_with_suggestion", ""),
|
||||
],
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows shell integration warning when Background Terminal mode is already enabled.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ShellIntegrationWarning: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Run a command"),
|
||||
createMessage(4.7, "say", "text", "I'll run the command for you."),
|
||||
createMessage(4.5, "say", "shell_integration_warning", ""),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows shell integration unavailable warning with instructions to update VSCode and select a supported shell.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorRetryInProgress: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Process a request"),
|
||||
createMessage(4.7, "say", "text", "Attempting to process your request."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"error_retry",
|
||||
JSON.stringify({ attempt: 2, maxAttempts: 5, delaySeconds: 10, failed: false }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows auto-retry in progress with attempt count and delay.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorRetryFailed: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Process a request"),
|
||||
createMessage(4.7, "say", "text", "Attempting to process your request."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"error_retry",
|
||||
JSON.stringify({ attempt: 5, maxAttempts: 5, delaySeconds: 0, failed: true }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows auto-retry failed after max attempts with manual intervention required.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const GenerateExplanationInProgress: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Explain my recent changes"),
|
||||
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"generate_explanation",
|
||||
JSON.stringify({
|
||||
title: "Authentication refactor",
|
||||
fromRef: "abc123def",
|
||||
toRef: "working directory",
|
||||
status: "generating",
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows explanation generation in progress with spinner.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const GenerateExplanationComplete: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Explain my recent changes"),
|
||||
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"generate_explanation",
|
||||
JSON.stringify({
|
||||
title: "Authentication refactor",
|
||||
fromRef: "abc123def",
|
||||
toRef: "xyz789ghi",
|
||||
status: "complete",
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows successfully generated explanation with git refs.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const GenerateExplanationError: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Explain my recent changes"),
|
||||
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"generate_explanation",
|
||||
JSON.stringify({
|
||||
title: "Authentication refactor",
|
||||
fromRef: "abc123def",
|
||||
toRef: "",
|
||||
status: "error",
|
||||
error: "Failed to generate explanation: Git repository not found",
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows explanation generation error with error message.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const GenerateExplanationCancelled: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({
|
||||
clineMessages: [
|
||||
createMessage(5, "say", "task", "Explain my recent changes"),
|
||||
createMessage(4.7, "say", "text", "I'll generate an explanation of your changes."),
|
||||
createMessage(
|
||||
4.5,
|
||||
"say",
|
||||
"generate_explanation",
|
||||
JSON.stringify({
|
||||
title: "Authentication refactor",
|
||||
fromRef: "abc123def",
|
||||
toRef: "",
|
||||
status: "generating",
|
||||
}),
|
||||
),
|
||||
createMessage(4.3, "ask", undefined, "Task was cancelled", { ask: "resume_task" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows explanation generation cancelled state (detected via resume_task message).",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Diff Edit Stories - New Format
|
||||
const createNewFormatMultiFileMessages = () => [
|
||||
createMessage(5, "say", "task", "Help me refactor the authentication module"),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const ClineCompactIcon = () => (
|
||||
<svg height="16" viewBox="0 0 92 96" width="16">
|
||||
<g fill="currentColor">
|
||||
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" />
|
||||
<ellipse cx="45.7349843" cy="11" rx="12" ry="14" />
|
||||
<ellipse cx="33.5" cy="55.5" rx="8" ry="9" />
|
||||
<ellipse cx="57.5" cy="55.5" rx="8" ry="9" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
ChatLayout,
|
||||
convertHtmlToMarkdown,
|
||||
filterVisibleMessages,
|
||||
groupLowStakesTools,
|
||||
groupMessages,
|
||||
InputSection,
|
||||
MessagesArea,
|
||||
@@ -192,10 +193,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
}, [])
|
||||
// Button state is now managed by useButtonState hook
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedRows({})
|
||||
}, [task?.ts])
|
||||
|
||||
// handleFocusChange is already provided by chatState
|
||||
|
||||
// Use message handlers hook
|
||||
@@ -324,7 +321,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
}, [modifiedMessages, currentFocusChainChecklist])
|
||||
|
||||
const groupedMessages = useMemo(() => {
|
||||
return groupMessages(visibleMessages)
|
||||
return groupLowStakesTools(groupMessages(visibleMessages))
|
||||
}, [visibleMessages])
|
||||
|
||||
// Use scroll behavior hook
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { ClineCompactIcon } from "@/assets/ClineCompactIcon"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import CodeBlock from "../common/CodeBlock"
|
||||
import ExpandHandle from "./ExpandHandle"
|
||||
|
||||
export const CommandOutputContent = memo(
|
||||
({
|
||||
output,
|
||||
isOutputFullyExpanded,
|
||||
onToggle,
|
||||
isContainerExpanded,
|
||||
}: {
|
||||
output: string
|
||||
isOutputFullyExpanded: boolean
|
||||
onToggle: () => void
|
||||
isContainerExpanded: boolean
|
||||
}) => {
|
||||
const outputLines = output.split("\n")
|
||||
const lineCount = outputLines.length
|
||||
const shouldAutoShow = lineCount <= 5
|
||||
const outputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom when output changes (only when showing limited output)
|
||||
useEffect(() => {
|
||||
if (!isOutputFullyExpanded && outputRef.current) {
|
||||
// Direct scrollTop manipulation
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
|
||||
// Another attempt with more delay (for slower renders) to ensure scrolling works
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, [output, isOutputFullyExpanded])
|
||||
|
||||
// Don't render anything if container is collapsed
|
||||
if (!isContainerExpanded) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if output contains a log file path indicator
|
||||
const logFilePathMatch = output.match(/📋 Output is being logged to: ([^\n]+)/)
|
||||
const logFilePath = logFilePathMatch ? logFilePathMatch[1].trim() : null
|
||||
|
||||
// Render output with clickable log file path
|
||||
const renderOutput = () => {
|
||||
if (!logFilePath) {
|
||||
return <CodeBlock forceWrap={true} source={`${"```"}shell\n${output}\n${"```"}`} />
|
||||
}
|
||||
|
||||
// Split output into parts: before log path, log path line, after log path
|
||||
const logPathLineStart = output.indexOf("📋 Output is being logged to:")
|
||||
const logPathLineEnd = output.indexOf("\n", logPathLineStart)
|
||||
const beforeLogPath = output.substring(0, logPathLineStart)
|
||||
const afterLogPath = logPathLineEnd !== -1 ? output.substring(logPathLineEnd) : ""
|
||||
|
||||
// Extract just the filename from the full path for display
|
||||
const fileName = logFilePath.split("/").pop() || logFilePath
|
||||
|
||||
return (
|
||||
<div className="border border-editor-group-border rounded-sm">
|
||||
{beforeLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${beforeLogPath}\n${"```"}`} />}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 px-3 py-2 mx-2 my-1.5 rounded-sm bg-banner-background cursor-pointer hover:brightness-110 transition-colors"
|
||||
onClick={() => {
|
||||
FileServiceClient.openFile(StringRequest.create({ value: logFilePath })).catch((err) =>
|
||||
console.error("Failed to open log file:", err),
|
||||
)
|
||||
}}
|
||||
title={`Click to open: ${logFilePath}`}>
|
||||
<span className="shrink-0">📋 Output is being logged to:</span>
|
||||
<span className="text-vscode-textLink-foreground underline break-all">{fileName}</span>
|
||||
</div>
|
||||
{afterLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${afterLogPath}\n${"```"}`} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full relative pb-0 overflow-visible border-t border-editor-group-border bg-code rounded-sm", {
|
||||
"rounded-b-none": lineCount > 5,
|
||||
})}>
|
||||
<div
|
||||
className={cn("text-white scroll-smooth bg-code overflow-y-auto", {
|
||||
"max-h-[75px]": !shouldAutoShow && !isOutputFullyExpanded,
|
||||
"max-h-[200px]": !shouldAutoShow && isOutputFullyExpanded,
|
||||
"overflow-y-visible": shouldAutoShow,
|
||||
})}
|
||||
ref={outputRef}>
|
||||
<div className="bg-code">{renderOutput()}</div>
|
||||
</div>
|
||||
{/* Show notch only if there's more than 5 lines */}
|
||||
{lineCount > 5 && <ExpandHandle isExpanded={isOutputFullyExpanded} onToggle={onToggle} />}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CommandOutputContent.displayName = "CommandOutputContent"
|
||||
|
||||
export const CommandOutputRow = memo(
|
||||
({
|
||||
message,
|
||||
isCommandExecuting = false,
|
||||
isCommandPending = false,
|
||||
isCommandCompleted = false,
|
||||
isBackgroundExec = false, // vscodeTerminalExecutionMode === "backgroundExec"
|
||||
onCancelCommand,
|
||||
icon,
|
||||
title,
|
||||
isOutputFullyExpanded,
|
||||
setIsOutputFullyExpanded,
|
||||
}: {
|
||||
message: ClineMessage
|
||||
isCommandExecuting?: boolean
|
||||
isCommandPending?: boolean
|
||||
isCommandCompleted?: boolean
|
||||
isBackgroundExec?: boolean
|
||||
onCancelCommand?: () => void
|
||||
icon?: JSX.Element | null
|
||||
title?: JSX.Element | null
|
||||
isOutputFullyExpanded: boolean
|
||||
setIsOutputFullyExpanded: (expanded: boolean) => void
|
||||
}) => {
|
||||
const splitMessage = (text: string) => {
|
||||
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
if (outputIndex === -1) {
|
||||
return { command: text, output: "" }
|
||||
}
|
||||
return {
|
||||
command: text.slice(0, outputIndex).trim(),
|
||||
output: text
|
||||
.slice(outputIndex + COMMAND_OUTPUT_STRING.length)
|
||||
.trim()
|
||||
.split("")
|
||||
.map((char) => {
|
||||
switch (char) {
|
||||
case "\t":
|
||||
return "→ "
|
||||
case "\b":
|
||||
return "⌫"
|
||||
case "\f":
|
||||
return "⏏"
|
||||
case "\v":
|
||||
return "⇳"
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
|
||||
const { command: rawCommand, output } = splitMessage(message.text || "")
|
||||
|
||||
const requestsApproval = rawCommand.endsWith(COMMAND_REQ_APP_STRING)
|
||||
const command = requestsApproval ? rawCommand.slice(0, -COMMAND_REQ_APP_STRING.length) : rawCommand
|
||||
const showCancelButton =
|
||||
(isCommandExecuting || isCommandPending) && typeof onCancelCommand === "function" && isBackgroundExec
|
||||
|
||||
// Check if this is a Cline subagent command (only on VSCode platform, not JetBrains/standalone)
|
||||
const isSubagentCommand = PLATFORM_CONFIG.type === PlatformType.VSCODE && command.trim().startsWith("cline ")
|
||||
let subagentPrompt: string | undefined
|
||||
|
||||
if (isSubagentCommand) {
|
||||
// Parse the cline command to extract prompt
|
||||
// Format: cline "prompt"
|
||||
const clineCommandRegex = /^cline\s+"([^"]+)"(?:\s+--no-interactive)?/
|
||||
const match = command.match(clineCommandRegex)
|
||||
|
||||
if (match) {
|
||||
subagentPrompt = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Customize icon and title for subagent commands
|
||||
const displayIcon = isSubagentCommand ? (
|
||||
<span className="text-foreground mb-[-1.5px]">
|
||||
<ClineCompactIcon />
|
||||
</span>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
|
||||
const displayTitle = isSubagentCommand ? (
|
||||
<span className="text-foreground font-bold">Cline wants to use a subagent:</span>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
|
||||
const commandHeader = (
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
{displayIcon}
|
||||
{displayTitle}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{commandHeader}
|
||||
<div
|
||||
className="bg-code rounded-sm border border-editor-group-border"
|
||||
style={{
|
||||
transition: "all 0.3s ease-in-out",
|
||||
}}>
|
||||
{command && (
|
||||
<div className="bg-code flex items-center justify-between px-2 py-2.5 border-b border-editor-group-border rounded-sm rounded-b-none overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-1 m-w-0">
|
||||
<div
|
||||
className={cn("bg-description rounded-full w-2 h-2 shrink-0", {
|
||||
"bg-success animate-pulse": isCommandExecuting,
|
||||
"bg-editor-warning-foreground": isCommandPending,
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={cn("text-description font-medium text-base shrink-0", {
|
||||
"text-success": isCommandExecuting,
|
||||
"text-editor-warning-foreground": isCommandPending,
|
||||
})}>
|
||||
{getCommandStatusText(isCommandExecuting, isCommandPending, isCommandCompleted)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{showCancelButton && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isBackgroundExec) {
|
||||
onCancelCommand?.()
|
||||
} else {
|
||||
// For regular terminal mode, show a message
|
||||
alert(
|
||||
"This command is running in the VSCode terminal. You can manually stop it using Ctrl+C in the terminal, or switch to Background Execution mode in settings for cancellable commands.",
|
||||
)
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
{isBackgroundExec ? "cancel" : "stop"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSubagentCommand && subagentPrompt && (
|
||||
<div className="p-2.5 border-b border-editor-group-border">
|
||||
<div className="mb-0">
|
||||
<strong>Prompt:</strong> <span className="ph-no-capture font-editor">{subagentPrompt}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSubagentCommand && (
|
||||
<div className="bg-code opacity-60 text-sm">
|
||||
<CodeBlock forceWrap={true} source={`${"```"}shell\n${command}\n${"```"}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output.length > 0 && (
|
||||
<CommandOutputContent
|
||||
isContainerExpanded={true}
|
||||
isOutputFullyExpanded={isOutputFullyExpanded}
|
||||
onToggle={() => setIsOutputFullyExpanded(!isOutputFullyExpanded)}
|
||||
output={output}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{requestsApproval && (
|
||||
<div className="flex items-center gap-2.5 p-2 text-[12px] text-editor-warning-foreground">
|
||||
<i className="codicon codicon-warning"></i>
|
||||
<span>The model has determined this command requires explicit approval.</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CommandOutputRow.displayName = "CommandOutputRow"
|
||||
|
||||
const CommandStatusMap = {
|
||||
executing: "Running",
|
||||
pending: "Pending",
|
||||
completed: "Completed",
|
||||
skipped: "Skipped",
|
||||
}
|
||||
|
||||
function getCommandStatusText(isExecuting: boolean, isPending: boolean, isCompleted: boolean): string {
|
||||
if (isExecuting) {
|
||||
return CommandStatusMap.executing
|
||||
}
|
||||
if (isPending) {
|
||||
return CommandStatusMap.pending
|
||||
}
|
||||
if (isCompleted) {
|
||||
return CommandStatusMap.completed
|
||||
}
|
||||
return CommandStatusMap.skipped
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Use theme-aware background and border colors for better contrast in all themes */
|
||||
.completion-output-content pre {
|
||||
background-color: rgba(0, 0, 0, 0.15) !important;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
}
|
||||
|
||||
.completion-output-content code {
|
||||
background-color: rgba(0, 0, 0, 0.15) !important;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
|
||||
}
|
||||
|
||||
.completion-output-content pre > code {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { memo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MarkdownRow } from "./MarkdownRow"
|
||||
import "./CompletionOutputRow.css"
|
||||
import { Int64Request } from "@shared/proto/cline/common"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { CopyButton } from "../common/CopyButton"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
import { QuoteButtonState } from "./ChatRow"
|
||||
import QuoteButton from "./QuoteButton"
|
||||
|
||||
interface CompletionOutputRowProps {
|
||||
text: string
|
||||
quoteButtonState: QuoteButtonState
|
||||
handleQuoteClick: () => void
|
||||
headClassNames?: string
|
||||
showActionRow?: boolean
|
||||
seeNewChangesDisabled: boolean
|
||||
setSeeNewChangesDisabled: (value: boolean) => void
|
||||
explainChangesDisabled: boolean
|
||||
setExplainChangesDisabled: (value: boolean) => void
|
||||
messageTs: number
|
||||
}
|
||||
|
||||
export const CompletionOutputRow = memo(
|
||||
({
|
||||
headClassNames,
|
||||
text,
|
||||
quoteButtonState,
|
||||
showActionRow,
|
||||
seeNewChangesDisabled,
|
||||
setSeeNewChangesDisabled,
|
||||
explainChangesDisabled,
|
||||
setExplainChangesDisabled,
|
||||
messageTs,
|
||||
handleQuoteClick,
|
||||
}: CompletionOutputRowProps) => {
|
||||
const outputLines = text.split("\n")
|
||||
const lineCount = outputLines.length
|
||||
const shouldAutoShow = lineCount <= 5
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-sm border border-success/20 overflow-visible bg-success/10 p-2 pt-3">
|
||||
{/* Title */}
|
||||
<div className={cn(headClassNames, "justify-between px-1")}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CheckIcon className="size-3 text-success" />
|
||||
<span className="text-success font-bold">Task Completed</span>
|
||||
</div>
|
||||
<CopyButton className="text-success" textToCopy={text} />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="w-full relative overflow-visible border-t-1 border-description/20 rounded-b-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"completion-output-content",
|
||||
"scroll-smooth p-2 pt-3 overflow-y-auto w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 rounded-sm max-h-[400px]",
|
||||
{
|
||||
"overflow-y-visible": shouldAutoShow,
|
||||
},
|
||||
)}>
|
||||
<MarkdownRow markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton left={quoteButtonState.left} onClick={handleQuoteClick} top={quoteButtonState.top} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
{showActionRow && (
|
||||
<CompletionOutputActionRow
|
||||
explainChangesDisabled={explainChangesDisabled}
|
||||
messageTs={messageTs}
|
||||
seeNewChangesDisabled={seeNewChangesDisabled}
|
||||
setExplainChangesDisabled={setExplainChangesDisabled}
|
||||
setSeeNewChangesDisabled={setSeeNewChangesDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CompletionOutputRow.displayName = "CompletionOutputRow"
|
||||
|
||||
const CompletionOutputActionRow = memo(
|
||||
({
|
||||
seeNewChangesDisabled,
|
||||
setSeeNewChangesDisabled,
|
||||
explainChangesDisabled,
|
||||
setExplainChangesDisabled,
|
||||
messageTs,
|
||||
}: {
|
||||
seeNewChangesDisabled: boolean
|
||||
setSeeNewChangesDisabled: (value: boolean) => void
|
||||
explainChangesDisabled: boolean
|
||||
setExplainChangesDisabled: (value: boolean) => void
|
||||
messageTs: number
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ paddingTop: 10, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SuccessButton
|
||||
disabled={seeNewChangesDisabled}
|
||||
onClick={() => {
|
||||
setSeeNewChangesDisabled(true)
|
||||
TaskServiceClient.taskCompletionViewChanges(
|
||||
Int64Request.create({
|
||||
value: messageTs,
|
||||
}),
|
||||
).catch((err) => console.error("Failed to show task completion view changes:", err))
|
||||
}}
|
||||
style={{
|
||||
cursor: seeNewChangesDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
}}>
|
||||
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
|
||||
View Changes
|
||||
</SuccessButton>
|
||||
|
||||
{PLATFORM_CONFIG.type === PlatformType.VSCODE && (
|
||||
<SuccessButton
|
||||
disabled={explainChangesDisabled}
|
||||
onClick={() => {
|
||||
setExplainChangesDisabled(true)
|
||||
TaskServiceClient.explainChanges({
|
||||
metadata: {},
|
||||
messageTs,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to explain changes:", err)
|
||||
setExplainChangesDisabled(false)
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
cursor: explainChangesDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
}}>
|
||||
<i className="codicon codicon-comment-discussion" style={{ marginRight: 6 }} />
|
||||
{explainChangesDisabled ? "Explaining..." : "Explain Changes"}
|
||||
</SuccessButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -98,15 +98,15 @@ const FileBlock = memo<{ file: Patch; isStreaming: boolean }>(
|
||||
const ActionIcon = actionStyle.icon
|
||||
|
||||
return (
|
||||
<div className="p-1 bg-code rounded-xs border border-editor-group-border">
|
||||
<div className="bg-code rounded-xs border border-editor-group-border">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 p-2 bg-code transition-colors rounded-t-xs justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
type="button">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("flex items-center gap-2", actionStyle.borderClass)}>
|
||||
<div className="flex items-center gap-3 flex-1 w-full overflow-hidden">
|
||||
<div className={cn("flex items-center gap-2 w-full", actionStyle.borderClass)}>
|
||||
<ActionIcon className={cn("w-5 h-5", actionStyle.iconClass)} />
|
||||
<span className="font-medium">{file.path}</span>
|
||||
<span className="font-medium truncate">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DiffStats additions={file.additions} deletions={file.deletions} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
import CreditLimitError from "@/components/chat/CreditLimitError"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
|
||||
import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
|
||||
|
||||
@@ -49,7 +49,7 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
|
||||
return (
|
||||
<p className="m-0 whitespace-pre-wrap text-(--vscode-errorForeground) wrap-anywhere">
|
||||
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
|
||||
{errorMessage}
|
||||
{requestId && <div>Request ID: {requestId}</div>}
|
||||
</p>
|
||||
@@ -87,14 +87,14 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
<div>
|
||||
{/* The user is signed in or not using cline provider */}
|
||||
{isClineProvider && !clineUser ? (
|
||||
<VSCodeButton className="w-full mb-4" disabled={isLoginLoading} onClick={handleSignIn}>
|
||||
<Button className="w-full mb-4" disabled={isLoginLoading} onClick={handleSignIn}>
|
||||
Sign in to Cline
|
||||
{isLoginLoading && (
|
||||
<span className="ml-1 animate-spin">
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</span>
|
||||
)}
|
||||
</VSCodeButton>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="mb-4 text-description">(Click "Retry" below)</span>
|
||||
)}
|
||||
@@ -104,18 +104,18 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
}
|
||||
|
||||
// Regular error message
|
||||
return <p className="m-0 whitespace-pre-wrap text-(--vscode-errorForeground) wrap-anywhere">{message.text}</p>
|
||||
return <p className="m-0 mt-4 whitespace-pre-wrap text-error wrap-anywhere">{message.text}</p>
|
||||
|
||||
case "diff_error":
|
||||
return (
|
||||
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-(--vscode-textBlockQuote-background) text-(--vscode-foreground)">
|
||||
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
|
||||
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "clineignore_error":
|
||||
return (
|
||||
<div className="flex flex-col p-2 rounded text-xs bg-(--vscode-textBlockQuote-background) text-(--vscode-foreground) opacity-80">
|
||||
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
|
||||
<div>
|
||||
Cline tried to access <code>{message.text}</code> which is blocked by the <code>.clineignore</code>
|
||||
file.
|
||||
@@ -130,11 +130,11 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
|
||||
// For diff_error and clineignore_error, we don't show the header separately
|
||||
if (errorType === "diff_error" || errorType === "clineignore_error") {
|
||||
return <>{renderErrorContent()}</>
|
||||
return renderErrorContent()
|
||||
}
|
||||
|
||||
// For other error types, show header + content
|
||||
return <>{renderErrorContent()}</>
|
||||
return renderErrorContent()
|
||||
})
|
||||
|
||||
export default ErrorRow
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TriangleIcon } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ExpandHandleProps {
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable expand/collapse handle component
|
||||
* Used by CompletionOutput, PlanCompletionOutput, CommandOutput, etc.
|
||||
*/
|
||||
const ExpandHandle = memo(({ isExpanded, onToggle, className = "bg-accent" }: ExpandHandleProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-2 left-1/2 transform -translate-x-1/2 flex justify-center items-center px-4 py-0.5 cursor-pointer bg-description transition-opacity border border-none rounded-b-sm shrink-0",
|
||||
className,
|
||||
)}
|
||||
onClick={onToggle}>
|
||||
<TriangleIcon className={cn("text-black fill-black", isExpanded ? "rotate-0" : "rotate-180")} size={8} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ExpandHandle
|
||||
@@ -0,0 +1,12 @@
|
||||
import { memo } from "react"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
|
||||
export const MarkdownRow = memo(({ markdown, showCursor }: { markdown?: string; showCursor?: boolean }) => {
|
||||
return (
|
||||
<div className="wrap-anywhere overflow-hidden">
|
||||
<MarkdownBlock markdown={markdown} showCursor={showCursor} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
MarkdownRow.displayName = "MarkdownRow"
|
||||
@@ -46,8 +46,6 @@ export const OptionsButtons = ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
paddingTop: 15,
|
||||
// marginTop: "22px",
|
||||
}}>
|
||||
{/* <div style={{ color: "var(--vscode-descriptionForeground)", fontSize: "11px", textTransform: "uppercase" }}>
|
||||
SELECT ONE:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NotepadTextIcon } from "lucide-react"
|
||||
import { memo, useMemo } from "react"
|
||||
import { CopyButton } from "@/components/common/CopyButton"
|
||||
import MarkdownBlock from "@/components/common/MarkdownBlock"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface PlanCompletionOutputProps {
|
||||
text: string
|
||||
onCopy?: () => void
|
||||
headClassNames?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled completion output for Plan Mode responses
|
||||
* Uses grayscale colors to distinguish from Act Mode's green success theme
|
||||
*/
|
||||
const PlanCompletionOutputRow = memo(({ text, headClassNames }: PlanCompletionOutputProps) => {
|
||||
const { shouldAutoShow } = useMemo(() => {
|
||||
const lineCount = text?.split("\n")?.length || 0
|
||||
const shouldAutoShow = lineCount <= 5
|
||||
return { lineCount, shouldAutoShow }
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-description/50 overflow-visible bg-code p-2 pt-3">
|
||||
{/* Header */}
|
||||
<div className={cn(headClassNames, "justify-between px-1")}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<NotepadTextIcon className="size-2" />
|
||||
<span className="text-foreground font-bold">Plan Created</span>
|
||||
</div>
|
||||
<CopyButton textToCopy={text || ""} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full relative overflow-visible border-t-1 border-description/20 rounded-b-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"plan-completion-content",
|
||||
"scroll-smooth p-2 pt-3 overflow-y-auto w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 max-h-[400px]",
|
||||
{
|
||||
"overflow-y-visible": shouldAutoShow,
|
||||
},
|
||||
)}>
|
||||
<div className="wrap-anywhere -mb-4 overflow-hidden [&_hr]:opacity-20">
|
||||
<MarkdownBlock markdown={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
PlanCompletionOutputRow.displayName = "PlanCompletionOutputRow"
|
||||
|
||||
export default PlanCompletionOutputRow
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { QuoteIcon } from "lucide-react"
|
||||
import React from "react"
|
||||
import styled from "styled-components"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface QuoteButtonProps {
|
||||
top: number
|
||||
@@ -15,42 +16,25 @@ interface ButtonContainerProps {
|
||||
}
|
||||
|
||||
const ButtonContainer = styled.div<ButtonContainerProps>`
|
||||
position: absolute;
|
||||
top: ${(props) => props.$top}px; // Use transient prop $top
|
||||
left: ${(props) => props.$left}px; // Use transient prop $left
|
||||
z-index: 10; // Ensure it's above the text
|
||||
background-color: var(--vscode-button-background);
|
||||
border: 1px solid var(--vscode-button-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
`
|
||||
|
||||
const QuoteButton: React.FC<QuoteButtonProps> = ({ top, left, onClick }) => {
|
||||
return (
|
||||
// Pass transient props to the styled component
|
||||
<ButtonContainer $left={left} $top={top} className="quote-button-class">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
<ButtonContainer $left={left} $top={top} className="quote-button-class absolute">
|
||||
<Button
|
||||
aria-label="Quote selection"
|
||||
className="p-3 h-auto min-w-auto rounded-md shadow-sm transition-transform hover:scale-105 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // Prevent triggering mouseup on the parent
|
||||
onClick()
|
||||
}}
|
||||
style={{ padding: "2px 4px", height: "auto", minWidth: "auto" }}
|
||||
size="sm"
|
||||
title="Quote selection in reply">
|
||||
{" "}
|
||||
{/* Adjust padding */}
|
||||
<span
|
||||
className="codicon codicon-quote"
|
||||
style={{ fontSize: "12px", color: "var(--vscode-button-foreground)" }}></span>{" "}
|
||||
{/* Adjust font size */}
|
||||
</VSCodeButton>
|
||||
<QuoteIcon className="size-2 fill-button-foreground rotate-180 stroke-1" />
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ const ReportBugPreview: React.FC<ReportBugPreviewProps> = ({ data }) => {
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="bg-(--vscode-badge-background) text-(--vscode-badge-foreground) rounded-[3px] p-[14px]">
|
||||
<h3 className="font-bold text-base mb-3 mt-0">{bugData.title || "Bug Report"}</h3>
|
||||
<div className="bg-badge-background/50 text-badge-foreground rounded-xs p-3">
|
||||
<h2 className="font-bold mb-3">{bugData.title || "Bug Report"}</h2>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
{bugData.what_happened && (
|
||||
|
||||
@@ -3,12 +3,13 @@ import { TaskFeedbackType } from "@shared/WebviewMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
|
||||
interface TaskFeedbackButtonsProps {
|
||||
messageTs: number
|
||||
isFromHistory?: boolean
|
||||
style?: React.CSSProperties
|
||||
classNames?: string
|
||||
}
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
@@ -19,7 +20,7 @@ const ButtonWrapper = styled.div`
|
||||
transform: scale(0.85);
|
||||
`
|
||||
|
||||
const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, isFromHistory = false, style }) => {
|
||||
const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, isFromHistory = false, classNames }) => {
|
||||
const [feedback, setFeedback] = useState<TaskFeedbackType | null>(null)
|
||||
const [shouldShow, setShouldShow] = useState<boolean>(true)
|
||||
|
||||
@@ -71,7 +72,7 @@ const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, is
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={style}>
|
||||
<div className={cn("flex items-center justify-end shrink-0", classNames)}>
|
||||
<ButtonsContainer>
|
||||
<ButtonWrapper>
|
||||
<VSCodeButton
|
||||
@@ -109,16 +110,10 @@ const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, is
|
||||
<span className="codicon codicon-bug" />
|
||||
</VSCodeButtonLink> */}
|
||||
</ButtonsContainer>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ThinkingRowProps {
|
||||
showTitle: boolean
|
||||
reasoningContent?: string
|
||||
isVisible: boolean
|
||||
isExpanded: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle }: ThinkingRowProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Only auto-scroll to bottom during streaming (showCursor=true)
|
||||
// For expanded collapsed thinking, start at top
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && isVisible) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [reasoningContent, isVisible])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
{showTitle ? (
|
||||
<Button
|
||||
className="inline-flex justify-baseline gap-0.5 text-left select-none cursor-pointer text-description px-0 w-full"
|
||||
onClick={onToggle}
|
||||
variant="icon">
|
||||
{isExpanded ? <ChevronDownIcon className="opacity-70" /> : <ChevronRightIcon className="opacity-70" />}
|
||||
<span className="font-semibold">Thinking:</span>
|
||||
<span className="italic break-words truncate [direction:rtl] w-full">
|
||||
{!isExpanded ? reasoningContent : ""}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isExpanded && (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex gap-0 overflow-hidden w-full min-w-0 max-h-0 opacity-0 items-baseline justify-baseline text-left p-0",
|
||||
"disabled:cursor-text disabled:opacity-100",
|
||||
{
|
||||
"max-h-[200px] opacity-100": isVisible,
|
||||
"transition-[max-height] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] [transition:max-height_250ms_cubic-bezier(0.4,0,0.2,1),opacity_150ms_ease-out]":
|
||||
isVisible,
|
||||
},
|
||||
)}
|
||||
disabled={!showTitle}
|
||||
onClick={onToggle}
|
||||
variant="text">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[150px] overflow-y-auto text-description leading-normal truncated whitespace-pre-wrap break-words flex-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [direction:ltr]",
|
||||
{
|
||||
"pl-2 border-l border-description/50": showTitle,
|
||||
},
|
||||
)}
|
||||
ref={scrollRef}>
|
||||
<span>{reasoningContent}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ThinkingRow.displayName = "ThinkingRow"
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { TypewriterText } from "./TypewriterText"
|
||||
|
||||
const meta: Meta<typeof TypewriterText> = {
|
||||
title: "Views/Components/TypewriterText",
|
||||
component: TypewriterText,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The TypewriterText component provides a typewriter animation effect that displays text character by character.
|
||||
|
||||
**Key Features:**
|
||||
- **Streaming Animation**: Characters appear one at a time with configurable speed
|
||||
- **Shimmer Effect**: After typing completes, text shows a subtle shimmer animation
|
||||
- **Performance Optimized**: Uses React.memo to prevent unnecessary re-renders
|
||||
- **Configurable Speed**: Control typing speed via the speed prop (milliseconds per character)
|
||||
|
||||
**Use Cases:**
|
||||
- AI response streaming visualization
|
||||
- Loading states with dynamic text
|
||||
- Enhanced user engagement during content generation
|
||||
- Visual feedback for progressive content delivery
|
||||
|
||||
**Props:**
|
||||
- \`text\`: The complete text to display with typewriter effect
|
||||
- \`speed\`: Typing speed in milliseconds per character (default: 30ms)
|
||||
|
||||
**Try It Out:**
|
||||
Use the controls below to test different text content and typing speeds interactively. The component will restart the animation whenever you change the text or speed.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
text: {
|
||||
control: "text",
|
||||
description: "The text to display with typewriter effect",
|
||||
table: {
|
||||
type: { summary: "string" },
|
||||
},
|
||||
},
|
||||
speed: {
|
||||
control: { type: "range", min: 10, max: 200, step: 10 },
|
||||
description: "Speed in milliseconds per character (lower = faster)",
|
||||
table: {
|
||||
type: { summary: "number" },
|
||||
defaultValue: { summary: "30" },
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-full max-w-2xl p-6 bg-[var(--vscode-editor-background)] text-[var(--vscode-editor-foreground)]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof TypewriterText>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "I'll help you create a responsive navigation component for your React application. Let me start by examining your current project structure and then create a modern, accessible navigation component with mobile-first design, keyboard navigation support, smooth animations, and dark/light theme support.",
|
||||
speed: 30,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `Interactive demo of the TypewriterText component. Use the controls panel below to:
|
||||
|
||||
- **Change the text**: Type any text to see the typewriter effect
|
||||
- **Adjust speed**: Use the slider to control typing speed (10ms = fast, 200ms = slow)
|
||||
- **Test different scenarios**: Try short messages, long paragraphs, code snippets, emojis, or special characters
|
||||
|
||||
The component automatically restarts the animation when you change either the text or speed. After typing completes, you'll see a subtle shimmer effect on the text.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { memo, useEffect, useState } from "react"
|
||||
|
||||
// TypewriterText with shimmer effect after typing completes
|
||||
export const TypewriterText = memo(({ text, speed = 30 }: { text: string; speed?: number }) => {
|
||||
const [displayedLength, setDisplayedLength] = useState(0)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedLength(0)
|
||||
setIsComplete(false)
|
||||
const interval = setInterval(() => {
|
||||
setDisplayedLength((prev) => {
|
||||
if (prev >= text.length) {
|
||||
clearInterval(interval)
|
||||
setIsComplete(true)
|
||||
return prev
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
}, speed)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [text, speed])
|
||||
|
||||
// After typing completes, show shimmer effect instead of blinking cursor
|
||||
if (isComplete) {
|
||||
return (
|
||||
<span className="animate-shimmer bg-linear-90 from-foreground to-description bg-[length:200%_100%] bg-clip-text text-transparent truncate">
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="truncate">{text.slice(0, displayedLength)}</span>
|
||||
})
|
||||
|
||||
TypewriterText.displayName = "TypewriterText"
|
||||
@@ -89,12 +89,10 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
|
||||
return (
|
||||
<div
|
||||
className="py-2 px-2.5"
|
||||
className="p-2.5 pr-1 my-1 text-badge-foreground rounded-xs"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
backgroundColor: isEditing ? "unset" : "var(--vscode-badge-background)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
borderRadius: "3px",
|
||||
whiteSpace: "pre-line",
|
||||
wordWrap: "break-word",
|
||||
}}>
|
||||
|
||||
@@ -87,8 +87,10 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
<div className="overflow-hidden flex flex-col h-full relative">
|
||||
{/* Sticky User Message - positioned absolutely to avoid layout shifts */}
|
||||
<div
|
||||
className={cn("absolute top-0 left-0 right-0 z-10 pl-[15px] pr-[14px]", scrolledPastUserMessage && "pb-2")}
|
||||
style={{ backgroundColor: "var(--vscode-sideBar-background)" }}>
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 pl-[15px] pr-[14px] bg-background",
|
||||
scrolledPastUserMessage && "pb-2",
|
||||
)}>
|
||||
<StickyUserMessage
|
||||
isVisible={!!scrolledPastUserMessage}
|
||||
lastUserMessage={scrolledPastUserMessage}
|
||||
@@ -106,9 +108,9 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
|
||||
}}
|
||||
atBottomThreshold={10} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
|
||||
className="scrollable"
|
||||
className="scrollable grow overflow-y-scroll"
|
||||
components={{
|
||||
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
|
||||
Footer: () => <div className="min-h-1" />, // Add empty padding at the bottom
|
||||
}}
|
||||
data={groupedMessages}
|
||||
// increasing top by 3_000 to prevent jumping around when user collapses a row
|
||||
@@ -122,8 +124,9 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
rangeChanged={handleRangeChanged}
|
||||
ref={virtuosoRef} // anything lower causes issues with followOutput
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE/Edge
|
||||
overflowAnchor: "none", // prevent scroll jump when content expands
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import React from "react"
|
||||
import React, { useMemo } from "react"
|
||||
import BrowserSessionRow from "@/components/chat/BrowserSessionRow"
|
||||
import ChatRow from "@/components/chat/ChatRow"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MessageHandlers } from "../../types/chatTypes"
|
||||
import { findReasoningForApiReq, isApiReqAbsorbable, isTextMessagePendingToolCall, isToolGroup } from "../../utils/messageUtils"
|
||||
import { ToolGroupRenderer } from "./ToolGroupRenderer"
|
||||
|
||||
interface MessageRendererProps {
|
||||
index: number
|
||||
@@ -34,12 +37,40 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
inputValue,
|
||||
messageHandlers,
|
||||
}) => {
|
||||
const { mode } = useExtensionState()
|
||||
const isLastMessage = index === groupedMessages?.length - 1
|
||||
|
||||
// Get reasoning content and response status for api_req_started messages
|
||||
const reasoningData = useMemo(() => {
|
||||
if (!Array.isArray(messageOrGroup) && messageOrGroup.say === "api_req_started") {
|
||||
// Use the same message source-of-truth that `groupedMessages` is derived from.
|
||||
return findReasoningForApiReq(messageOrGroup.ts, modifiedMessages)
|
||||
}
|
||||
return { reasoning: undefined, responseStarted: false }
|
||||
}, [messageOrGroup, modifiedMessages])
|
||||
|
||||
// Check if a text message is waiting for tool call completion
|
||||
const isRequestInProgress = useMemo(() => {
|
||||
if (!Array.isArray(messageOrGroup) && messageOrGroup.say === "text") {
|
||||
// Use modifiedMessages so this stays consistent with the rendered list.
|
||||
return isTextMessagePendingToolCall(messageOrGroup.ts, modifiedMessages)
|
||||
}
|
||||
return false
|
||||
}, [messageOrGroup, modifiedMessages])
|
||||
|
||||
// Tool group (low-stakes tools grouped together)
|
||||
// Always render - the loading state in ChatRow shows current activities,
|
||||
// while ToolGroupRenderer shows what's in context. Overlap is fine.
|
||||
if (isToolGroup(messageOrGroup)) {
|
||||
return <ToolGroupRenderer allMessages={modifiedMessages} messages={messageOrGroup} />
|
||||
}
|
||||
|
||||
// Browser session group
|
||||
if (Array.isArray(messageOrGroup)) {
|
||||
return (
|
||||
<BrowserSessionRow
|
||||
expandedRows={expandedRows}
|
||||
isLast={index === groupedMessages.length - 1}
|
||||
isLast={isLastMessage}
|
||||
key={messageOrGroup[0]?.ts}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
messages={messageOrGroup}
|
||||
@@ -54,26 +85,41 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
const nextMessage = index < groupedMessages.length - 1 && groupedMessages[index + 1]
|
||||
const isNextCheckpoint = !Array.isArray(nextMessage) && nextMessage && nextMessage?.say === "checkpoint_created"
|
||||
const isLastMessageGroup = isNextCheckpoint && index === groupedMessages.length - 2
|
||||
const isLast = index === groupedMessages.length - 1 || isLastMessageGroup
|
||||
const isLastMessageOrGroup = isLastMessage || isLastMessageGroup
|
||||
|
||||
// Deterministic flash fix:
|
||||
// If this api_req_started is meant to be absorbed into a low-stakes tool group,
|
||||
// never render it as a standalone row.
|
||||
// BUT: Only absorb if this isn't the last/only message (to avoid hiding completed task api_reqs)
|
||||
if (
|
||||
messageOrGroup.say === "api_req_started" &&
|
||||
(!isLastMessageOrGroup || isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Regular message
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"pb-2.5": isLast,
|
||||
"pb-2.5": isLastMessage,
|
||||
})}
|
||||
data-message-ts={messageOrGroup.ts}>
|
||||
<ChatRow
|
||||
inputValue={inputValue}
|
||||
isExpanded={expandedRows[messageOrGroup.ts] || false}
|
||||
isLast={isLast}
|
||||
isLast={isLastMessage}
|
||||
isRequestInProgress={isRequestInProgress}
|
||||
key={messageOrGroup.ts}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
message={messageOrGroup}
|
||||
mode={mode}
|
||||
onCancelCommand={() => messageHandlers.executeButtonAction("cancel")}
|
||||
onHeightChange={onHeightChange}
|
||||
onSetQuote={onSetQuote}
|
||||
onToggleExpand={onToggleExpand}
|
||||
reasoningContent={reasoningData.reasoning}
|
||||
responseStarted={reasoningData.responseStarted}
|
||||
sendMessageFromChatRow={messageHandlers.handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import { getIconByToolName, getToolsNotInCurrentActivities, isLowStakesTool } from "../../utils/messageUtils"
|
||||
|
||||
interface ToolGroupRendererProps {
|
||||
messages: ClineMessage[]
|
||||
allMessages: ClineMessage[]
|
||||
}
|
||||
|
||||
interface ToolWithReasoning {
|
||||
tool: ClineMessage
|
||||
parsedTool: ClineSayTool
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
const EXPANDABLE_TOOLS = new Set(["listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"])
|
||||
|
||||
/**
|
||||
* Renders a collapsible group of low-stakes tool calls.
|
||||
* Only shows tools that are NOT in the "current activities" range (PAST tools only).
|
||||
*/
|
||||
export const ToolGroupRenderer = memo(({ messages, allMessages }: ToolGroupRendererProps) => {
|
||||
const [expandedItems, setExpandedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Filter out tools in the "current activities" range (being shown in loading state)
|
||||
const filteredMessages = useMemo(() => getToolsNotInCurrentActivities(messages, allMessages), [messages, allMessages])
|
||||
|
||||
// Build tool items with associated reasoning (reasoning that comes BEFORE a tool)
|
||||
const toolsWithReasoning = useMemo(() => buildToolsWithReasoning(filteredMessages), [filteredMessages])
|
||||
|
||||
const summary = getToolGroupSummary(filteredMessages)
|
||||
|
||||
const handleOpenFile = useCallback((filePath: string) => {
|
||||
FileServiceClient.openFileRelativePath(StringRequest.create({ value: filePath })).catch((err) =>
|
||||
console.error("Failed to open file:", err),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleItemToggle = useCallback((ts: number) => {
|
||||
setExpandedItems((prev) => ({ ...prev, [ts]: !prev[ts] }))
|
||||
}, [])
|
||||
|
||||
// Don't render if no PAST tools to show
|
||||
if (toolsWithReasoning.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2 text-description")}>
|
||||
{/* Header */}
|
||||
<div className="text-[13px] opacity-90 mb-1">{summary}:</div>
|
||||
|
||||
{/* Content - files/folders with reasoning in tooltip */}
|
||||
<div className="min-w-0">
|
||||
{toolsWithReasoning.map(({ tool, parsedTool, reasoning }) => {
|
||||
const info = getToolDisplayInfo(parsedTool)
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isExpandable = EXPANDABLE_TOOLS.has(parsedTool.tool)
|
||||
const isItemExpanded = expandedItems[tool.ts] ?? false
|
||||
const content = parsedTool.content || null
|
||||
const hasReasoning = !!reasoning?.length
|
||||
|
||||
return (
|
||||
<div className="min-w-0" key={tool.ts}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-1.5 cursor-pointer text-[13px] text-description py-0.5 hover:text-link min-w-0 max-w-full px-0"
|
||||
onClick={() => (isExpandable ? handleItemToggle(tool.ts) : handleOpenFile(info.path))}
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[13px]" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left [direction:rtl] text-[13px]",
|
||||
{
|
||||
"[direction:ltr]": !!info.displayText,
|
||||
},
|
||||
)}>
|
||||
{(info.displayText || cleanPathPrefix(info.path)) + "\u200E"}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{hasReasoning && <TooltipContent side="bottom">{reasoning}</TooltipContent>}
|
||||
</Tooltip>
|
||||
{/* Expanded content for folders/search/definitions - raw text */}
|
||||
{isExpandable && isItemExpanded && content && (
|
||||
<pre className="m-1 ml-4 text-xs opacity-80 whitespace-pre-wrap break-words p-2 max-h-40 overflow-auto rounded-xs">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Build tool items with associated reasoning (reasoning that comes BEFORE a tool).
|
||||
* Only processes low-stakes tools, accumulating reasoning messages along the way.
|
||||
*/
|
||||
function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReasoning[] {
|
||||
const result: ToolWithReasoning[] = []
|
||||
const reasoningBuffer: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "reasoning" && msg.text) {
|
||||
reasoningBuffer.push(msg.text)
|
||||
} else if (isLowStakesTool(msg)) {
|
||||
const parsedTool = parseToolSafe(msg.text)
|
||||
result.push({
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: reasoningBuffer.length > 0 ? reasoningBuffer.join("\n\n") : undefined,
|
||||
})
|
||||
reasoningBuffer.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse tool JSON, returning empty tool on failure.
|
||||
*/
|
||||
function parseToolSafe(text: string | undefined): ClineSayTool {
|
||||
try {
|
||||
return JSON.parse(text || "{}") as ClineSayTool
|
||||
} catch {
|
||||
return {} as ClineSayTool
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display info for a tool.
|
||||
*/
|
||||
function getToolDisplayInfo(tool: ClineSayTool) {
|
||||
const icon = getIconByToolName(tool.tool)
|
||||
const filePath = tool.path || ""
|
||||
const folderPath = filePath + "/"
|
||||
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
return { icon, path: filePath, label: "read" }
|
||||
case "listFilesTopLevel":
|
||||
return { icon, path: folderPath, label: "listed" }
|
||||
case "listFilesRecursive":
|
||||
return { icon, path: folderPath, label: "listed recursively" }
|
||||
case "listCodeDefinitionNames":
|
||||
return { icon, path: folderPath, label: "definitions" }
|
||||
case "searchFiles":
|
||||
return {
|
||||
icon,
|
||||
path: folderPath,
|
||||
label: `search: ${tool.regex}`,
|
||||
displayText: formatSearchDisplay(tool.regex || "", filePath, tool.filePattern),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search regex for display - simplify complex patterns
|
||||
*/
|
||||
function formatSearchDisplay(regex: string, path: string, filePattern?: string): string {
|
||||
// Split by | and clean up regex syntax
|
||||
const terms = regex
|
||||
.split("|")
|
||||
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
|
||||
.filter(Boolean)
|
||||
|
||||
const termDisplay = terms.length > 3 ? `${terms.length} patterns` : `"${terms.join(" | ")}"`
|
||||
let result = `${termDisplay} in ${cleanPathPrefix(path)}/`
|
||||
|
||||
if (filePattern && filePattern !== "*") {
|
||||
result += ` (${filePattern})`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary label for a tool group - shows what's been added to context.
|
||||
*/
|
||||
function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
const counts = { read: 0, list: 0, search: 0, def: 0 }
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!isLowStakesTool(msg)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tool = parseToolSafe(msg.text)
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
counts.read++
|
||||
break
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
counts.list++
|
||||
break
|
||||
case "searchFiles":
|
||||
counts.search++
|
||||
break
|
||||
case "listCodeDefinitionNames":
|
||||
counts.def++
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
const action = counts.read > 0 || counts.list > 0 ? " read " : " "
|
||||
|
||||
if (counts.read > 0) {
|
||||
parts.push(`${counts.read} file${counts.read > 1 ? "s" : ""}`)
|
||||
}
|
||||
if (counts.list > 0) {
|
||||
parts.push(`${counts.list} folder${counts.list > 1 ? "s" : ""}`)
|
||||
}
|
||||
if (counts.def > 0) {
|
||||
parts.push(`${counts.def} definition${counts.def > 1 ? "s" : ""}`)
|
||||
}
|
||||
if (counts.search > 0) {
|
||||
parts.push(`performed ${counts.search} search${counts.search > 1 ? "es" : ""}`)
|
||||
}
|
||||
|
||||
return parts.length === 0 ? "Context" : "Cline" + action + parts.join(", ")
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChatState } from "../types/chatTypes"
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,11 @@ export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
setIsTextAreaFocused(isFocused)
|
||||
}, [])
|
||||
|
||||
// Auto-expand last message row when task or messages first changed.
|
||||
useEffect(() => {
|
||||
clearExpandedRows()
|
||||
}, [task?.ts, clearExpandedRows])
|
||||
|
||||
return {
|
||||
// State values
|
||||
inputValue,
|
||||
|
||||
@@ -240,31 +240,22 @@ export function useScrollBehavior(
|
||||
if (!isCollapsing) {
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
|
||||
// Only scroll on collapse, never on expand - expanding should stay in place
|
||||
if (isCollapsing && isAtBottom) {
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
if (isCollapsing) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: groupedMessages.length - (isLast ? 1 : 2),
|
||||
align: "start",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isCollapsing && (isLast || isSecondToLast)) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
// When expanding, don't scroll - let the element expand in place
|
||||
},
|
||||
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom],
|
||||
)
|
||||
|
||||
@@ -4,7 +4,41 @@
|
||||
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { FileIcon, FolderOpenDotIcon, FolderOpenIcon, SearchIcon, ShapesIcon, WrenchIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Low-stakes tool types that should be grouped together
|
||||
*/
|
||||
const LOW_STAKES_TOOLS = new Set([
|
||||
"readFile",
|
||||
"listFilesTopLevel",
|
||||
"listFilesRecursive",
|
||||
"listCodeDefinitionNames",
|
||||
"searchFiles",
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a tool message is a low-stakes tool
|
||||
*/
|
||||
export function isLowStakesTool(message: ClineMessage): boolean {
|
||||
if (message.say !== "tool" && message.ask !== "tool") {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const tool = JSON.parse(message.text || "{}") as ClineSayTool
|
||||
return LOW_STAKES_TOOLS.has(tool.tool)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message group is a tool group (array with _isToolGroup marker)
|
||||
*/
|
||||
export function isToolGroup(item: ClineMessage | ClineMessage[]): item is ClineMessage[] & { _isToolGroup: true } {
|
||||
return Array.isArray(item) && (item as any)._isToolGroup === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine API requests and command sequences in messages
|
||||
@@ -36,6 +70,11 @@ export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[]
|
||||
case "deleted_api_reqs": // aggregated api_req metrics from deleted messages
|
||||
case "task_progress": // task progress messages are displayed in TaskHeader, not in main chat
|
||||
return false
|
||||
// NOTE: reasoning passes through to be included in tool groups
|
||||
case "api_req_started":
|
||||
// Keep api_req_started visible so the Brain "thinking" UI can remain above
|
||||
// subsequent tool/text output (especially for non-exploratory operations).
|
||||
break
|
||||
case "text":
|
||||
// Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
|
||||
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
|
||||
@@ -151,3 +190,589 @@ export function getTaskMessage(messages: ClineMessage[]): ClineMessage | undefin
|
||||
export function shouldShowScrollButton(disableAutoScroll: boolean, isAtBottom: boolean): boolean {
|
||||
return disableAutoScroll && !isAtBottom
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reasoning content associated with an api_req_started message.
|
||||
* Also returns whether response content (non-reasoning) has started.
|
||||
*/
|
||||
export function findReasoningForApiReq(
|
||||
apiReqTs: number,
|
||||
allMessages: ClineMessage[],
|
||||
): { reasoning: string | undefined; responseStarted: boolean } {
|
||||
const apiReqIndex = allMessages.findIndex((m) => m.ts === apiReqTs && m.say === "api_req_started")
|
||||
if (apiReqIndex === -1) {
|
||||
return { reasoning: undefined, responseStarted: false }
|
||||
}
|
||||
|
||||
// Collect reasoning and check if response content has started
|
||||
const reasoningParts: string[] = []
|
||||
let responseStarted = false
|
||||
|
||||
for (let i = apiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Stop at next api_req_started
|
||||
if (msg.say === "api_req_started") {
|
||||
break
|
||||
}
|
||||
// Collect reasoning content
|
||||
if (msg.say === "reasoning" && msg.text) {
|
||||
reasoningParts.push(msg.text)
|
||||
}
|
||||
// Check if non-reasoning response content has started (text, tool calls, etc.)
|
||||
if (msg.say === "text" || msg.say === "tool" || msg.ask === "tool" || msg.ask === "command" || msg.say === "command") {
|
||||
responseStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : undefined,
|
||||
responseStarted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the API request info for a checkpoint message.
|
||||
* Looks backwards from the checkpoint to find the preceding api_req_started.
|
||||
* Returns cost and request content.
|
||||
*/
|
||||
export function findApiReqInfoForCheckpoint(
|
||||
checkpointTs: number,
|
||||
allMessages: ClineMessage[],
|
||||
): { cost: number | undefined; request: string | undefined } {
|
||||
const checkpointIndex = allMessages.findIndex((m) => m.ts === checkpointTs && m.say === "checkpoint_created")
|
||||
if (checkpointIndex === -1) {
|
||||
return { cost: undefined, request: undefined }
|
||||
}
|
||||
|
||||
// Look backwards for the most recent api_req_started
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
return {
|
||||
cost: info.cost,
|
||||
request: info.request,
|
||||
}
|
||||
} catch {
|
||||
return { cost: undefined, request: undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cost: undefined, request: undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a checkpoint at the given index would be displayed (not absorbed into a tool group).
|
||||
* A checkpoint is absorbed if it's PRECEDED by low-stakes tools (meaning we're in a tool group).
|
||||
* A checkpoint is displayed if it's preceded by non-tool content (meaning no active tool group).
|
||||
*/
|
||||
function isDisplayedCheckpoint(checkpointIndex: number, allMessages: ClineMessage[]): boolean {
|
||||
// Look BACKWARDS to see if we're in a tool group
|
||||
// A checkpoint is absorbed if the previous meaningful content was a low-stakes tool
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
|
||||
// Skip api_req messages - they don't affect tool group status
|
||||
if (msg.say === "api_req_started" || msg.say === "api_req_finished") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip reasoning messages
|
||||
if (msg.say === "reasoning") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other checkpoints - they don't end tool groups
|
||||
if (msg.say === "checkpoint_created") {
|
||||
continue
|
||||
}
|
||||
|
||||
// If preceded by a low-stakes tool, this checkpoint is in the tool group (absorbed)
|
||||
if (msg.say === "tool" || msg.ask === "tool") {
|
||||
try {
|
||||
const tool = JSON.parse(msg.text || "{}") as ClineSayTool
|
||||
if (LOW_STAKES_TOOLS.has(tool.tool)) {
|
||||
return false // absorbed into tool group
|
||||
}
|
||||
} catch {
|
||||
// Can't parse, treat as displayed
|
||||
}
|
||||
}
|
||||
|
||||
// Any other content before this checkpoint ends the tool group, so this is displayed
|
||||
return true
|
||||
}
|
||||
|
||||
// Start of messages - checkpoint is displayed (no preceding tool group)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the total cost for the segment starting at a checkpoint.
|
||||
* Looks FORWARD from the checkpoint to the next DISPLAYED checkpoint (skipping absorbed ones).
|
||||
* Sums all api_req_started costs in between.
|
||||
* Returns undefined if the segment is incomplete (no next displayed checkpoint yet).
|
||||
*/
|
||||
export function findNextSegmentCost(checkpointTs: number, allMessages: ClineMessage[]): number | undefined {
|
||||
const checkpointIndex = allMessages.findIndex((m) => m.ts === checkpointTs && m.say === "checkpoint_created")
|
||||
if (checkpointIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
// Find the next DISPLAYED checkpoint (skip absorbed ones)
|
||||
let nextDisplayedCheckpointIndex = -1
|
||||
for (let i = checkpointIndex + 1; i < allMessages.length; i++) {
|
||||
if (allMessages[i].say === "checkpoint_created") {
|
||||
if (isDisplayedCheckpoint(i, allMessages)) {
|
||||
nextDisplayedCheckpointIndex = i
|
||||
break
|
||||
}
|
||||
// Otherwise continue looking for next displayed checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// If no next displayed checkpoint, sum to end of messages (in-progress segment)
|
||||
const endIndex = nextDisplayedCheckpointIndex === -1 ? allMessages.length : nextDisplayedCheckpointIndex
|
||||
|
||||
// Sum all api_req_started costs between this checkpoint and the end
|
||||
let totalCost = 0
|
||||
for (let i = checkpointIndex + 1; i < endIndex; i++) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
if (typeof info.cost === "number") {
|
||||
totalCost += info.cost
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalCost > 0 ? totalCost : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text message's associated API request is still in progress.
|
||||
* Returns true if there's no cost yet on the parent api_req_started.
|
||||
*/
|
||||
export function isTextMessagePendingToolCall(textTs: number, allMessages: ClineMessage[]): boolean {
|
||||
// Find the api_req_started that precedes this text message
|
||||
const textIndex = allMessages.findIndex((m) => m.ts === textTs)
|
||||
if (textIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Look backwards for the most recent api_req_started
|
||||
for (let i = textIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
// If no cost, the request is still in progress
|
||||
return info.cost == null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool group should be hidden because its tools are currently being
|
||||
* displayed in the loading state animation.
|
||||
*
|
||||
* Returns true ONLY when:
|
||||
* 1. The MOST RECENT api_req_started overall has no cost (loading state is active)
|
||||
* 2. This tool group falls in the "current activities" range (between the previous
|
||||
* completed api_req and the current one)
|
||||
*
|
||||
* This mirrors the ChatRow currentActivities logic - we only hide tools that are
|
||||
* actively being shown in the loading state, not older tool groups.
|
||||
*/
|
||||
export function isToolGroupInFlight(toolGroupMessages: ClineMessage[], allMessages: ClineMessage[]): boolean {
|
||||
if (toolGroupMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Step 1: Find the MOST RECENT api_req_started overall (search backwards)
|
||||
let mostRecentApiReq: ClineMessage | null = null
|
||||
let mostRecentApiReqIndex = -1
|
||||
for (let i = allMessages.length - 1; i >= 0; i--) {
|
||||
if (allMessages[i].say === "api_req_started") {
|
||||
mostRecentApiReq = allMessages[i]
|
||||
mostRecentApiReqIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentApiReq?.text) {
|
||||
return false
|
||||
}
|
||||
// Step 2: Check if it's in "pre" state (no cost = loading state active)
|
||||
try {
|
||||
const info = JSON.parse(mostRecentApiReq.text)
|
||||
if (info.cost != null) {
|
||||
// Loading state is NOT active - show all tool groups in ToolGroupRenderer
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 3: Loading state IS active. Find the previous COMPLETED api_req.
|
||||
let prevCompletedApiReqIndex = -1
|
||||
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const prevInfo = JSON.parse(msg.text)
|
||||
if (prevInfo.cost != null) {
|
||||
prevCompletedApiReqIndex = i
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
/* continue searching */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no previous completed api_req, there's no "current activities" range.
|
||||
// ChatRow's currentActivities returns empty in this case, so don't hide the tool group.
|
||||
if (prevCompletedApiReqIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 4: Check if any tool in this group falls in the "current activities" range
|
||||
const lastTool = [...toolGroupMessages].reverse().find((m) => isLowStakesTool(m))
|
||||
if (!lastTool) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolIndex = allMessages.findIndex((m) => m.ts === lastTool.ts)
|
||||
if (toolIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Tool is in the "current activities" range if it's AFTER prevCompleted and BEFORE current
|
||||
const isInCurrentActivitiesRange = toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
|
||||
|
||||
return isInCurrentActivitiesRange
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a tool group to exclude tools that are in the "current activities" range.
|
||||
* Returns the filtered array of messages (may be empty).
|
||||
*
|
||||
* This is used so ToolGroupRenderer shows PAST tools (what's already in context),
|
||||
* while the loading state shows ACTIVE tools (what's being "read" now).
|
||||
*/
|
||||
export function getToolsNotInCurrentActivities(toolGroupMessages: ClineMessage[], allMessages: ClineMessage[]): ClineMessage[] {
|
||||
// Step 1: Find the MOST RECENT api_req_started overall (search backwards)
|
||||
let mostRecentApiReqIndex = -1
|
||||
for (let i = allMessages.length - 1; i >= 0; i--) {
|
||||
if (allMessages[i].say === "api_req_started") {
|
||||
mostRecentApiReqIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecentApiReqIndex === -1) {
|
||||
return toolGroupMessages
|
||||
}
|
||||
|
||||
// Step 2: Check if it's in "pre" state (no cost = loading state active)
|
||||
const mostRecentApiReq = allMessages[mostRecentApiReqIndex]
|
||||
if (!mostRecentApiReq?.text) {
|
||||
return toolGroupMessages
|
||||
}
|
||||
|
||||
let isLoadingStateActive = false
|
||||
try {
|
||||
const info = JSON.parse(mostRecentApiReq.text)
|
||||
isLoadingStateActive = info.cost == null
|
||||
} catch {
|
||||
return toolGroupMessages
|
||||
}
|
||||
|
||||
if (!isLoadingStateActive) {
|
||||
// Loading state is NOT active - show all tools
|
||||
return toolGroupMessages
|
||||
}
|
||||
|
||||
// Step 3: Loading state IS active. Find the previous COMPLETED api_req.
|
||||
let prevCompletedApiReqIndex = -1
|
||||
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const prevInfo = JSON.parse(msg.text)
|
||||
if (prevInfo.cost != null) {
|
||||
prevCompletedApiReqIndex = i
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
/* continue searching */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no previous completed api_req, there's no "current activities" range
|
||||
if (prevCompletedApiReqIndex === -1) {
|
||||
return toolGroupMessages
|
||||
}
|
||||
|
||||
// Step 4: Filter out tools that are in the "current activities" range
|
||||
return toolGroupMessages.filter((msg) => {
|
||||
// Only filter tool messages
|
||||
if (!isLowStakesTool(msg)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const toolIndex = allMessages.findIndex((m) => m.ts === msg.ts)
|
||||
if (toolIndex === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tool is in "current activities" range if AFTER prevCompleted AND BEFORE current
|
||||
const isInCurrentActivitiesRange = toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
|
||||
|
||||
// Keep only if NOT in current activities range
|
||||
return !isInCurrentActivitiesRange
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this api_req_started should be fully absorbed into a low-stakes tool group.
|
||||
*
|
||||
* This scans FORWARD from the api_req_started until the next api_req_started and checks:
|
||||
* - at least one low-stakes tool exists
|
||||
* - no high-stakes tool/command exists
|
||||
*
|
||||
* Note: this operates on a flat `ClineMessage[]` (e.g. `modifiedMessages`) rather than
|
||||
* grouped messages. It is used at render time to avoid transient UI frames where
|
||||
* `api_req_started` briefly appears before grouping absorbs it.
|
||||
*/
|
||||
export function isApiReqAbsorbable(apiReqTs: number, allMessages: ClineMessage[]): boolean {
|
||||
const apiReqIndex = allMessages.findIndex((m) => m.ts === apiReqTs && m.say === "api_req_started")
|
||||
if (apiReqIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasLowStakesTool = false
|
||||
for (let i = apiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started") {
|
||||
break
|
||||
}
|
||||
|
||||
// Reasoning and checkpoints do not affect absorbability
|
||||
if (msg.say === "reasoning" || msg.say === "checkpoint_created") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Text is allowed (we still want to absorb api_req into the tool group)
|
||||
if (msg.say === "text") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Low-stakes tools mark absorbability
|
||||
if (isLowStakesTool(msg)) {
|
||||
hasLowStakesTool = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Any other tool/command is considered high-stakes; do not absorb
|
||||
if (msg.say === "tool" || msg.ask === "tool" || msg.say === "command" || msg.ask === "command") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return hasLowStakesTool
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an api_req_started at a given index produces low-stakes tools
|
||||
* (regardless of whether it also produces text).
|
||||
* If so, it should be absorbed into the tool group rather than rendered separately.
|
||||
* The key is: no HIGH-stakes tools (write, edit, command, etc.)
|
||||
*/
|
||||
function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMessage | ClineMessage[])[]): boolean {
|
||||
let hasLowStakesTool = false
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (Array.isArray(item)) {
|
||||
// Browser session - this ends the low-stakes run
|
||||
break
|
||||
}
|
||||
const msg = item
|
||||
// Another api_req_started - stop checking
|
||||
if (msg.say === "api_req_started") {
|
||||
break
|
||||
}
|
||||
// Reasoning is allowed
|
||||
if (msg.say === "reasoning") {
|
||||
continue
|
||||
}
|
||||
// Low-stakes tool - mark it
|
||||
if (isLowStakesTool(msg)) {
|
||||
hasLowStakesTool = true
|
||||
continue
|
||||
}
|
||||
// Checkpoint is OK
|
||||
if (msg.say === "checkpoint_created") {
|
||||
continue
|
||||
}
|
||||
// Text is OK - it will render separately, but we still absorb api_req
|
||||
if (msg.say === "text") {
|
||||
continue
|
||||
}
|
||||
// High-stakes tool (write, edit, command, etc.) - don't absorb
|
||||
if (msg.say === "tool" || msg.ask === "tool" || msg.ask === "command" || msg.say === "command") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLowStakesTool
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive low-stakes tools (and their reasoning) into arrays.
|
||||
* Also filters out checkpoints that follow low-stakes tool groups.
|
||||
* Absorbs api_req_started messages that are followed only by low-stakes tools.
|
||||
* Only creates tool groups when there's at least one actual tool - reasoning-only groups are dropped.
|
||||
* Should be called after groupMessages.
|
||||
*/
|
||||
export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessage[])[]): (ClineMessage | ClineMessage[])[] {
|
||||
const result: (ClineMessage | ClineMessage[])[] = []
|
||||
let toolGroup: ClineMessage[] = []
|
||||
let pendingReasoning: ClineMessage[] = []
|
||||
let pendingApiReq: ClineMessage[] = []
|
||||
let hasTools = false
|
||||
const pendingTools: ClineMessage[] = []
|
||||
|
||||
const flushPending = () => {
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
pendingApiReq = []
|
||||
pendingReasoning = []
|
||||
}
|
||||
|
||||
const commitToolGroup = () => {
|
||||
if (toolGroup.length > 0 && hasTools) {
|
||||
const group = toolGroup as ClineMessage[] & { _isToolGroup: boolean }
|
||||
group._isToolGroup = true
|
||||
result.push(group)
|
||||
pendingReasoning = []
|
||||
pendingApiReq = []
|
||||
}
|
||||
toolGroup = []
|
||||
hasTools = false
|
||||
}
|
||||
|
||||
const absorbPending = () => {
|
||||
if (pendingApiReq.length > 0) {
|
||||
toolGroup.push(...pendingApiReq)
|
||||
pendingApiReq = []
|
||||
}
|
||||
if (pendingReasoning.length > 0) {
|
||||
toolGroup.push(...pendingReasoning)
|
||||
pendingReasoning = []
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < groupedMessages.length; i++) {
|
||||
const item = groupedMessages[i]
|
||||
|
||||
// Browser session group - commit current work and pass through
|
||||
if (Array.isArray(item)) {
|
||||
commitToolGroup()
|
||||
flushPending()
|
||||
result.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const message = item
|
||||
const messageType = message.say
|
||||
const isLast = i === groupedMessages.length - 1
|
||||
|
||||
// Low-stakes tool - absorb pending and add to group
|
||||
if (isLowStakesTool(message)) {
|
||||
absorbPending()
|
||||
hasTools = true
|
||||
toolGroup.push(message)
|
||||
// If the streaming has stopped and the last message is still an ask,
|
||||
// this means the tool requires user approval - show the old tool block UI.
|
||||
if (message.type === "ask" && !message.partial && isLast) {
|
||||
pendingTools.push(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reasoning - add to group if active, otherwise queue
|
||||
if (messageType === "reasoning") {
|
||||
if (hasTools) {
|
||||
toolGroup.push(message)
|
||||
} else {
|
||||
pendingReasoning.push(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// API request - absorb if followed by low-stakes tools, otherwise render
|
||||
if (messageType === "api_req_started") {
|
||||
if (isApiReqFollowedOnlyByLowStakesTools(i, groupedMessages)) {
|
||||
absorbPending()
|
||||
pendingApiReq.push(message)
|
||||
} else {
|
||||
commitToolGroup()
|
||||
flushPending()
|
||||
result.push(message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Checkpoint - absorb into active tool group
|
||||
if (messageType === "checkpoint_created" && hasTools) {
|
||||
toolGroup.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
// Text - render separately, keep pending for potential future tools
|
||||
if (messageType === "text") {
|
||||
result.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else - commit group, flush pending, and render
|
||||
commitToolGroup()
|
||||
flushPending()
|
||||
result.push(message)
|
||||
}
|
||||
|
||||
// Finalize any remaining work
|
||||
commitToolGroup()
|
||||
flushPending()
|
||||
|
||||
if (pendingTools.length > 0) {
|
||||
result.push(...pendingTools)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getIconByToolName(toolName: string) {
|
||||
switch (toolName) {
|
||||
case "readFile":
|
||||
return FileIcon
|
||||
case "listFilesTopLevel":
|
||||
return FolderOpenIcon
|
||||
case "listFilesRecursive":
|
||||
return FolderOpenDotIcon
|
||||
case "searchFiles":
|
||||
return SearchIcon
|
||||
case "listCodeDefinitionNames":
|
||||
return ShapesIcon
|
||||
default:
|
||||
return WrenchIcon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
const environmentBorderColor = getEnvironmentColor(environment, "border")
|
||||
|
||||
return (
|
||||
<div className="pt-2 pb-2 pl-[15px] pr-[14px] flex flex-col gap-2">
|
||||
<div className="py-2 px-4 flex flex-col gap-2">
|
||||
{/* Display Checkpoint Error */}
|
||||
<CheckpointError
|
||||
checkpointManagerErrorMessage={checkpointManagerErrorMessage}
|
||||
@@ -119,7 +119,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
"relative overflow-hidden cursor-pointer rounded-sm flex flex-col gap-1.5 z-10 pt-2 pb-2 px-2 hover:opacity-100 bg-(--vscode-toolbar-hoverBackground)/65",
|
||||
{
|
||||
"opacity-100 border-1": isTaskExpanded, // No hover effects when expanded, add border
|
||||
"hover:bg-(--vscode-toolbar-hoverBackground) border-1": !isTaskExpanded, // Hover effects only when collapsed
|
||||
"hover:bg-toolbar-hover border-1": !isTaskExpanded, // Hover effects only when collapsed
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface BannerActions {
|
||||
label: string
|
||||
@@ -38,17 +39,18 @@ const BannerCardContent: React.FC<BannerCardContentProps> = ({ banner, isActive,
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3"
|
||||
className={cn("p-3 transition-opacity duration-400 ease-in-out opacity-0", {
|
||||
"opacity-100": isActive && !isTransitioning,
|
||||
"cursor-auto": isActive,
|
||||
})}
|
||||
style={{
|
||||
gridArea: "stack",
|
||||
opacity: isActive && !isTransitioning ? 1 : 0,
|
||||
transition: "opacity 0.4s ease-in-out",
|
||||
pointerEvents: isActive ? "auto" : "none",
|
||||
}}>
|
||||
{/* Title with optional icon */}
|
||||
<h3
|
||||
className="font-semibold mb-2 flex items-center gap-2 text-base"
|
||||
style={{ paddingRight: showDismissButton ? "24px" : "0" }}>
|
||||
className={cn("font-semibold mb-2 flex items-center gap-2 text-base pr-0", {
|
||||
"pr-6": showDismissButton,
|
||||
})}>
|
||||
<span className="shrink-0">{banner.icon}</span>
|
||||
{banner.title}
|
||||
</h3>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { flip, offset, shift, useFloating } from "@floating-ui/react"
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import { Int64Request } from "@shared/proto/cline/common"
|
||||
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { BookmarkIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckpointsServiceClient } from "@/services/grpc-client"
|
||||
|
||||
interface CheckmarkControlProps {
|
||||
@@ -21,6 +23,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
|
||||
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const { onRelinquishControl } = useExtensionState()
|
||||
|
||||
// Debounce
|
||||
@@ -92,6 +95,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
setRestoreWorkspaceDisabled(false)
|
||||
setRestoreBothDisabled(false)
|
||||
setShowRestoreConfirm(false)
|
||||
setShowMoreOptions(false)
|
||||
})
|
||||
}, [onRelinquishControl])
|
||||
|
||||
@@ -165,19 +169,19 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
isMenuOpen={showRestoreConfirm}
|
||||
onMouseEnter={handleControlsMouseEnter}
|
||||
onMouseLeave={handleControlsMouseLeave}>
|
||||
<i
|
||||
className="codicon codicon-bookmark"
|
||||
style={{
|
||||
color: isCheckpointCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
<BookmarkIcon
|
||||
className={cn("text-xs text-description shrink-0 size-2", {
|
||||
"text-link": isCheckpointCheckedOut,
|
||||
})}
|
||||
/>
|
||||
<DottedLine $isCheckedOut={isCheckpointCheckedOut} className="hover-show-inverse" />
|
||||
<div className="hover-content">
|
||||
<Label $isCheckedOut={isCheckpointCheckedOut}>
|
||||
<span
|
||||
className={cn("text-[9px] text-description shrink-0", {
|
||||
"text-link": isCheckpointCheckedOut,
|
||||
})}>
|
||||
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
|
||||
</Label>
|
||||
</span>
|
||||
<DottedLine $isCheckedOut={isCheckpointCheckedOut} />
|
||||
<ButtonGroup>
|
||||
<CustomButton
|
||||
@@ -216,52 +220,67 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreWorkspaceDisabled || isCheckpointCheckedOut}
|
||||
onClick={handleRestoreWorkspace}
|
||||
style={{
|
||||
cursor: isCheckpointCheckedOut
|
||||
? "not-allowed"
|
||||
: restoreWorkspaceDisabled
|
||||
? "wait"
|
||||
: "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
Restore Files
|
||||
</VSCodeButton>
|
||||
<p>
|
||||
Restores your project's files back to a snapshot taken at this point (use "Compare" to
|
||||
see what will be reverted)
|
||||
</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreTaskDisabled}
|
||||
onClick={handleRestoreTask}
|
||||
style={{
|
||||
cursor: restoreTaskDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
Restore Task Only
|
||||
</VSCodeButton>
|
||||
<p>Deletes messages after this point (does not affect workspace files)</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
<PrimaryRestoreOption>
|
||||
<Button
|
||||
disabled={restoreBothDisabled}
|
||||
onClick={handleRestoreBoth}
|
||||
style={{
|
||||
cursor: restoreBothDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
<i className="codicon codicon-debug-restart" style={{ marginRight: "6px" }} />
|
||||
Restore Files & Task
|
||||
</VSCodeButton>
|
||||
<p>Restores your project's files and deletes all messages after this point</p>
|
||||
</RestoreOption>
|
||||
</Button>
|
||||
<p>Revert files and clear messages after this point</p>
|
||||
</PrimaryRestoreOption>
|
||||
|
||||
<MoreOptionsToggle onClick={() => setShowMoreOptions(!showMoreOptions)}>
|
||||
More options
|
||||
<i
|
||||
className={`codicon codicon-chevron-${showMoreOptions ? "up" : "down"}`}
|
||||
style={{ marginLeft: "4px", fontSize: "10px" }}
|
||||
/>
|
||||
</MoreOptionsToggle>
|
||||
|
||||
{showMoreOptions && (
|
||||
<AdditionalOptions>
|
||||
<RestoreOption>
|
||||
<Button
|
||||
disabled={restoreWorkspaceDisabled || isCheckpointCheckedOut}
|
||||
onClick={handleRestoreWorkspace}
|
||||
style={{
|
||||
cursor: isCheckpointCheckedOut
|
||||
? "not-allowed"
|
||||
: restoreWorkspaceDisabled
|
||||
? "wait"
|
||||
: "pointer",
|
||||
}}
|
||||
variant="secondary">
|
||||
<i
|
||||
className="codicon codicon-file-symlink-directory"
|
||||
style={{ marginRight: "6px" }}
|
||||
/>
|
||||
Restore Files Only
|
||||
</Button>
|
||||
<p>Revert files to this checkpoint</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<Button
|
||||
disabled={restoreTaskDisabled}
|
||||
onClick={handleRestoreTask}
|
||||
style={{
|
||||
cursor: restoreTaskDisabled ? "wait" : "pointer",
|
||||
}}
|
||||
variant="secondary">
|
||||
<i
|
||||
className="codicon codicon-comment-discussion"
|
||||
style={{ marginRight: "6px" }}
|
||||
/>
|
||||
Restore Task Only
|
||||
</Button>
|
||||
<p>Clear messages after this point</p>
|
||||
</RestoreOption>
|
||||
</AdditionalOptions>
|
||||
)}
|
||||
</RestoreConfirmTooltip>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -281,8 +300,8 @@ const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 17px;
|
||||
margin-top: -10px;
|
||||
margin-bottom: -10px;
|
||||
margin-top: -2px;
|
||||
margin-bottom: 1px;
|
||||
opacity: ${(props) => (props.$isCheckedOut ? 1 : props.isMenuOpen ? 1 : 0.5)};
|
||||
height: 0.5rem;
|
||||
&:hover {
|
||||
@@ -310,12 +329,6 @@ const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const Label = styled.span<{ $isCheckedOut?: boolean }>`
|
||||
color: ${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
|
||||
font-size: 9px;
|
||||
shrink: 0;
|
||||
`
|
||||
|
||||
const DottedLine = styled.div<{ small?: boolean; $isCheckedOut?: boolean }>`
|
||||
flex: ${(props) => (props.small ? "0 0 5px" : "1")};
|
||||
min-width: ${(props) => (props.small ? "5px" : "5px")};
|
||||
@@ -394,22 +407,64 @@ const CustomButton = styled.button<{ disabled?: boolean; isActive?: boolean; $is
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreOption = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
}
|
||||
const PrimaryRestoreOption = styled.div`
|
||||
margin-bottom: 12px;
|
||||
|
||||
p {
|
||||
margin: 0 0 2px 0;
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
&:last-child p {
|
||||
margin: 0 0 -2px 0;
|
||||
const MoreOptionsToggle = styled.button`
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
transition: opacity 0.1s ease;
|
||||
opacity: 0.8;
|
||||
margin-bottom: -4px;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const AdditionalOptions = styled.div`
|
||||
padding-top: 8px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--vscode-editorGroup-border);
|
||||
animation: slideDown 0.15s ease-out;
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreOption = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -417,10 +472,11 @@ const RestoreConfirmTooltip = styled.div`
|
||||
position: fixed;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
width: min(calc(100vw - 54px), 600px);
|
||||
padding: 14px;
|
||||
border-radius: 5px;
|
||||
width: min(calc(100vw - 54px), 200px);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
// Add invisible padding to create a safe hover zone
|
||||
&::before {
|
||||
@@ -463,9 +519,10 @@ const RestoreConfirmTooltip = styled.div`
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 6px 0;
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const CheckpointOverlay = ({ messageTs }: CheckpointOverlayProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckpointControls onMouseLeave={handleControlsMouseLeave}>
|
||||
<CheckpointControls className="hover:opacity-100" onMouseLeave={handleControlsMouseLeave}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={compareDisabled}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo, useMemo } from "react"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import CodeBlock from "@/components/common/CodeBlock"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getLanguageFromPath } from "@/utils/getLanguageFromPath"
|
||||
import { Button } from "../ui/button"
|
||||
|
||||
interface CodeAccordianProps {
|
||||
code?: string
|
||||
@@ -46,96 +48,51 @@ const CodeAccordian = ({
|
||||
}, [code])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
overflow: "hidden", // This ensures the inner scrollable area doesn't overflow the rounded corners
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
}}>
|
||||
<div className="bg-code overflow-hidden rounded-xs border border-editor-group-border">
|
||||
{(path || isFeedback || isConsoleLogs) && (
|
||||
<div
|
||||
<Button
|
||||
aria-label={isExpanded ? "Collapse code block" : "Expand code block"}
|
||||
className={cn("text-description flex items-center cursor-pointer select-none w-full py-[9px] px-2.5", {
|
||||
"cursor-wait opacity-70": isLoading,
|
||||
})}
|
||||
onClick={isLoading ? undefined : onToggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (isLoading) return
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (!isLoading) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onToggleExpand()
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation()
|
||||
onToggleExpand()
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "9px 10px",
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
// pointerEvents: isLoading ? "none" : "auto",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
tabIndex={0}>
|
||||
tabIndex={0}
|
||||
variant="text">
|
||||
{isFeedback || isConsoleLogs ? (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span
|
||||
className={`codicon codicon-${isFeedback ? "feedback" : "output"}`}
|
||||
style={{ marginRight: "6px" }}></span>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
marginRight: "8px",
|
||||
}}>
|
||||
<div className="flex items-center">
|
||||
<span className={`mr-1.5 codicon codicon-${isFeedback ? "feedback" : "output"}`} />
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis mr-2">
|
||||
{isFeedback ? "User Edits" : "Console Logs"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis mr-2 [direction: rtl] text-left">
|
||||
{path?.startsWith(".") && <span>.</span>}
|
||||
{path && !path.startsWith(".") && <span>/</span>}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
marginRight: "8px",
|
||||
// trick to get ellipsis at beginning of string
|
||||
direction: "rtl",
|
||||
textAlign: "left",
|
||||
}}>
|
||||
{cleanPathPrefix(path ?? "") + "\u200E"}
|
||||
</span>
|
||||
</>
|
||||
{cleanPathPrefix(path ?? "") + "\u200E"}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<div className="grow" />
|
||||
{numberOfEdits !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginRight: "8px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<span className="codicon codicon-diff-single" style={{ marginRight: "4px" }}></span>
|
||||
<div className="flex items-center mr-2 text-description">
|
||||
<span className="codicon codicon-diff-single mr-1" />
|
||||
<span>{numberOfEdits}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
|
||||
</div>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`} />
|
||||
</Button>
|
||||
)}
|
||||
{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
|
||||
<div
|
||||
//className="code-block-scrollable" this doesn't seem to be necessary anymore, on silicon macs it shows the native mac scrollbar instead of the vscode styled one
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
<div className="overflow-x-auto overflow-y-hidden max-w-full">
|
||||
<CodeBlock
|
||||
source={`${"```"}${diff !== undefined ? "diff" : inferredLanguage}\n${(
|
||||
code ?? diff ?? ""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { forwardRef, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
|
||||
// ======== Interfaces ========
|
||||
import { CheckCheckIcon, CopyIcon } from "lucide-react"
|
||||
import { forwardRef, useCallback, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CopyButtonProps {
|
||||
textToCopy?: string
|
||||
@@ -17,85 +16,49 @@ interface WithCopyButtonProps {
|
||||
onCopy?: () => string | undefined | null
|
||||
position?: "top-right" | "bottom-right"
|
||||
style?: React.CSSProperties
|
||||
copyButtonStyle?: React.CSSProperties
|
||||
className?: string
|
||||
copyButtonClassname?: string
|
||||
onMouseUp?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
// ======== Styled Components ========
|
||||
const COPIED_TIMEOUT = 1500
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
z-index: 1;
|
||||
transform: scale(0.9);
|
||||
`
|
||||
|
||||
// Unified container component
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
// Unified button container with flexible positioning
|
||||
const ButtonContainer = styled.div<{ $position?: "top-right" | "bottom-right" }>`
|
||||
position: absolute;
|
||||
${(props) => {
|
||||
switch (props.$position) {
|
||||
case "bottom-right":
|
||||
return "bottom: 2px; right: 2px;"
|
||||
case "top-right":
|
||||
default:
|
||||
return "top: 5px; right: 5px;"
|
||||
}
|
||||
}}
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
|
||||
${ContentContainer}:hover & {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
// ======== Component Implementations ========
|
||||
const POSITION_CLASSES = {
|
||||
"top-right": "top-5 right-5",
|
||||
"bottom-right": "bottom-1 right-2",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Base copy button component with clipboard functionality
|
||||
*/
|
||||
export const CopyButton: React.FC<CopyButtonProps> = ({ textToCopy, onCopy, className = "", ariaLabel }) => {
|
||||
export const CopyButton: React.FC<CopyButtonProps> = ({ textToCopy, onCopy, className, ariaLabel }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!textToCopy && !onCopy) {
|
||||
const handleCopy = useCallback(() => {
|
||||
const text = onCopy?.() || textToCopy
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
let textToCopyFinal = textToCopy
|
||||
|
||||
if (onCopy) {
|
||||
const result = onCopy()
|
||||
if (typeof result === "string") {
|
||||
textToCopyFinal = result
|
||||
}
|
||||
}
|
||||
|
||||
if (textToCopyFinal) {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopyFinal)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
.catch((err) => console.error("Copy failed", err))
|
||||
}
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), COPIED_TIMEOUT)
|
||||
})
|
||||
.catch((err) => console.error("Copy failed", err))
|
||||
}, [textToCopy, onCopy])
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
appearance="icon"
|
||||
<Button
|
||||
aria-label={copied ? "Copied" : ariaLabel || "Copy"}
|
||||
className={className}
|
||||
onClick={handleCopy}>
|
||||
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
|
||||
</StyledButton>
|
||||
className={cn("scale-90", className)}
|
||||
onClick={handleCopy}
|
||||
size="icon"
|
||||
variant="icon">
|
||||
{copied ? <CheckCheckIcon className="size-2" /> : <CopyIcon className="size-2" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,34 +73,32 @@ export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
|
||||
onCopy,
|
||||
position = "top-right",
|
||||
style,
|
||||
copyButtonStyle,
|
||||
className,
|
||||
copyButtonClassname,
|
||||
onMouseUp,
|
||||
ariaLabel, // Destructure ariaLabel
|
||||
ariaLabel,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const hasCopyFunctionality = !!(textToCopy || onCopy)
|
||||
|
||||
return (
|
||||
<ContentContainer className={className} onMouseUp={onMouseUp} ref={ref} style={style} {...props}>
|
||||
{children}
|
||||
{(textToCopy || onCopy) && (
|
||||
<ButtonContainer $position={position} style={copyButtonStyle}>
|
||||
<CopyButton
|
||||
ariaLabel={ariaLabel}
|
||||
onCopy={onCopy}
|
||||
textToCopy={textToCopy} // Pass through the ariaLabel prop directly
|
||||
/>
|
||||
</ButtonContainer>
|
||||
<div className={cn("group relative w-full", className)} onMouseUp={onMouseUp} ref={ref} style={style} {...props}>
|
||||
{hasCopyFunctionality && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
POSITION_CLASSES[position],
|
||||
copyButtonClassname,
|
||||
)}>
|
||||
<CopyButton ariaLabel={ariaLabel} onCopy={onCopy} textToCopy={textToCopy} />
|
||||
</div>
|
||||
)}
|
||||
</ContentContainer>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// Default export for convenience if needed, though named exports are preferred for clarity
|
||||
const CopyButtonComponents = {
|
||||
CopyButton,
|
||||
WithCopyButton,
|
||||
}
|
||||
export default CopyButtonComponents
|
||||
WithCopyButton.displayName = "WithCopyButton"
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import MarkdownBlock from "./MarkdownBlock"
|
||||
|
||||
const meta: Meta<typeof MarkdownBlock> = {
|
||||
title: "Views/Components/MarkdownBlock",
|
||||
component: MarkdownBlock,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The MarkdownBlock component renders markdown content with advanced features for code display, syntax highlighting, and interactive elements.
|
||||
|
||||
**Key Features:**
|
||||
- **Syntax Highlighting**: Automatic code highlighting using rehype-highlight
|
||||
- **File Path Detection**: Detects file paths in inline code and makes them clickable
|
||||
- **Act Mode Highlighting**: Special styling for "Act Mode" mentions with keyboard shortcuts
|
||||
- **URL Auto-linking**: Converts plain URLs in text to clickable links
|
||||
- **Mermaid Diagrams**: Supports mermaid diagram rendering in code blocks
|
||||
- **Copy Buttons**: Automatic copy buttons for code blocks
|
||||
- **Filename Protection**: Prevents filenames like __init__.py from being rendered as bold
|
||||
|
||||
**Use Cases:**
|
||||
- Rendering AI assistant responses with code snippets
|
||||
- Displaying formatted documentation
|
||||
- Interactive file navigation within markdown
|
||||
- Technical content with syntax highlighting
|
||||
|
||||
**Props:**
|
||||
- \`markdown\`: The markdown string to render
|
||||
- \`compact\`: Removes paragraph margins for compact display
|
||||
- \`showCursor\`: Shows a blinking cursor after content (for streaming effect)
|
||||
|
||||
**Try It Out:**
|
||||
Use the controls below to test different markdown content and see how various features work.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
markdown: {
|
||||
control: "text",
|
||||
description: "The markdown content to render",
|
||||
table: {
|
||||
type: { summary: "string" },
|
||||
},
|
||||
},
|
||||
compact: {
|
||||
control: "boolean",
|
||||
description: "Enables compact mode with reduced spacing",
|
||||
table: {
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
},
|
||||
showCursor: {
|
||||
control: "boolean",
|
||||
description: "Shows a blinking cursor after the content",
|
||||
table: {
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-full max-w-3xl p-6 bg-[var(--vscode-editor-background)] text-[var(--vscode-editor-foreground)]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof MarkdownBlock>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
markdown: `Here's a TypeScript example with syntax highlighting:
|
||||
|
||||
\`\`\`typescript
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
const response = await fetch(\`/api/users/\${id}\`)
|
||||
return response.json()
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The code above demonstrates a simple API fetch function.`,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Basic markdown rendering with text, inline code for file paths, and lists.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithActMode: Story = {
|
||||
args: {
|
||||
markdown: `I've analyzed your request. To proceed with implementation, please switch to Act Mode using the keyboard shortcut.
|
||||
|
||||
Once you're ready, I can execute the necessary file changes and command operations.`,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Demonstrates the special "Act Mode" highlighting feature with clickable toggle and keyboard shortcut display.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithLinks: Story = {
|
||||
args: {
|
||||
markdown: `Check out these resources:
|
||||
|
||||
- Official documentation: https://github.com/cline/cline
|
||||
- API reference: https://api.cline.bot/docs
|
||||
- Support forum: https://community.cline.bot
|
||||
|
||||
You can also visit our website at https://cline.bot for more information.`,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows automatic URL detection and conversion to clickable links. Plain URLs in text are converted to links automatically.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMermaidDiagram: Story = {
|
||||
args: {
|
||||
markdown: `Here's a diagram showing the component architecture:
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A[User Request] --> B{Extension}
|
||||
B --> C[Controller]
|
||||
C --> D[Task]
|
||||
D --> E[API Provider]
|
||||
E --> F[AI Model]
|
||||
F --> G[Response]
|
||||
G --> D
|
||||
D --> H[Tool Execution]
|
||||
H --> I[File System]
|
||||
H --> J[Terminal]
|
||||
H --> K[Browser]
|
||||
\`\`\`
|
||||
|
||||
This flow shows how requests are processed through the system.`,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Demonstrates Mermaid diagram rendering. The diagram is automatically detected and rendered as an interactive SVG.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDiffSyntax: Story = {
|
||||
args: {
|
||||
markdown: `Here are the changes I made to config.ts:
|
||||
|
||||
\`\`\`diff
|
||||
export const config = {
|
||||
- apiUrl: 'http://localhost:3000',
|
||||
+ apiUrl: process.env.API_URL || 'http://localhost:3000',
|
||||
timeout: 5000,
|
||||
- retries: 3,
|
||||
+ retries: 5,
|
||||
}
|
||||
\`\`\``,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows diff-style syntax highlighting with additions (+) and deletions (-) color-coded.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CompactMode: Story = {
|
||||
args: {
|
||||
markdown: `Compact mode removes extra spacing between paragraphs.
|
||||
|
||||
This is useful for inline messages or notifications.
|
||||
|
||||
All formatting still works: **bold**, *italic*, and [links](https://example.com).`,
|
||||
compact: true,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Demonstrates compact mode with reduced vertical spacing. Useful for tight layouts or inline messages.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithCursor: Story = {
|
||||
args: {
|
||||
markdown: `I'm analyzing your codebase`,
|
||||
compact: false,
|
||||
showCursor: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the blinking cursor effect, typically used during streaming responses to indicate ongoing generation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const FilenameProtection: Story = {
|
||||
args: {
|
||||
markdown:
|
||||
"I need to update these Python files:\n\n\n- `__init__.py` - Package initializer\n- `__main__.py` - Entry point\n- `__version__.py` - Version info\n\nThe `__init__.py` file should not render with bold text.",
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Demonstrates filename protection. Filenames like __init__.py are protected from being parsed as markdown bold (**text**).",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
markdown: `# Try Different Markdown!
|
||||
|
||||
Edit the markdown content in the controls panel to test:
|
||||
|
||||
## Features to Try:
|
||||
- **Bold text** and *italic text*
|
||||
- 'inline code' and file paths like \`src/App.tsx\`
|
||||
- Links: https://example.com
|
||||
- Mentions of switching to Act Mode
|
||||
|
||||
## Code Blocks:
|
||||
\`\`\`javascript
|
||||
console.log("Hello, World!")
|
||||
\`\`\`
|
||||
|
||||
## Lists:
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
---
|
||||
|
||||
Toggle the controls to test compact mode and showCursor!`,
|
||||
compact: false,
|
||||
showCursor: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `Interactive playground for testing MarkdownBlock. Use the controls panel to:
|
||||
|
||||
- **Edit markdown**: Type or paste any markdown content
|
||||
- **Toggle compact mode**: See how spacing changes
|
||||
- **Toggle cursor**: Add a blinking cursor effect
|
||||
- **Test features**: Try file paths, code blocks, links, Act Mode mentions, diagrams, etc.
|
||||
|
||||
This is perfect for experimenting with different markdown patterns and seeing how they render in real-time.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,27 +1,31 @@
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react"
|
||||
import type { ComponentProps } from "react"
|
||||
import React, { memo, useEffect, useRef } from "react"
|
||||
import React, { memo, useEffect, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import rehypeHighlight, { Options } from "rehype-highlight"
|
||||
import styled from "styled-components"
|
||||
import type { Node } from "unist"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import MermaidBlock from "@/components/common/MermaidBlock"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
|
||||
import { WithCopyButton } from "./CopyButton"
|
||||
|
||||
// Styled component for Act Mode text with more specific styling
|
||||
/**
|
||||
* A component for Act Mode text that contains a clickable toggle and keyboard shortcut hint.
|
||||
*/
|
||||
const ActModeHighlight: React.FC = () => {
|
||||
const { mode } = useExtensionState()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-(--vscode-textLink-foreground) inline-flex items-center gap-1 ${
|
||||
mode === "plan" ? "hover:opacity-90 cursor-pointer" : "cursor-default opacity-60"
|
||||
}`}
|
||||
className={cn("text-link inline-flex items-center gap-1", {
|
||||
"hover:opacity-90 cursor-pointer": mode === "plan",
|
||||
"cursor-not-allowed opacity-60": mode !== "plan",
|
||||
})}
|
||||
onClick={() => {
|
||||
// Only toggle to Act mode if we're currently in Plan mode
|
||||
if (mode === "plan") {
|
||||
@@ -33,8 +37,8 @@ const ActModeHighlight: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
title={mode === "plan" ? "Click to toggle to Act Mode" : "Already in Act Mode"}>
|
||||
<div className="p-1 rounded-[12px] bg-(--vscode-editor-background) flex items-center justify-end w-4 border border-(--vscode-input-border)">
|
||||
<div className="rounded-full bg-(--vscode-textLink-foreground) w-2 h-2" />
|
||||
<div className="p-1 rounded-md bg-code flex items-center justify-end w-7 border border-input-border">
|
||||
<div className="rounded-full bg-link w-2 h-2" />
|
||||
</div>
|
||||
Act Mode (⌘⇧A)
|
||||
</span>
|
||||
@@ -44,6 +48,7 @@ const ActModeHighlight: React.FC = () => {
|
||||
interface MarkdownBlockProps {
|
||||
markdown?: string
|
||||
compact?: boolean
|
||||
showCursor?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,106 +211,6 @@ const remarkPreventBoldFilenames = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const StyledMarkdown = styled.div<{ compact?: boolean }>`
|
||||
pre {
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
border-radius: 3px;
|
||||
margin: 13px 0;
|
||||
padding: 10px 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-right: 70px;
|
||||
}
|
||||
|
||||
pre > code {
|
||||
.hljs-deletion {
|
||||
background-color: var(--vscode-diffEditor-removedTextBackground);
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
.hljs-addition {
|
||||
background-color: var(--vscode-diffEditor-insertedTextBackground);
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
span.line:empty {
|
||||
display: none;
|
||||
}
|
||||
word-wrap: break-word;
|
||||
border-radius: 3px;
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
code:not(pre > code) {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
color: var(--vscode-textPreformat-foreground, #f78383);
|
||||
background-color: var(--vscode-textCodeBlock-background, #1e1e1e);
|
||||
padding: 0px 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--vscode-textSeparator-foreground, #424242);
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: var(--vscode-font-size, 13px);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
${(props) => props.compact && "margin: 0;"}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
hr, ul, ol {
|
||||
margin: 13px 0;
|
||||
}
|
||||
|
||||
li > ul, li > ol {
|
||||
margin: 4px 0; /* or 0 if you want them very tight */
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
const PreWithCopyButton = ({ children, ...preProps }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
@@ -331,46 +236,88 @@ const PreWithCopyButton = ({ children, ...preProps }: React.HTMLAttributes<HTMLP
|
||||
)
|
||||
}
|
||||
|
||||
// Regex to detect potential file paths (used in both remark plugin and component)
|
||||
const FILE_PATH_REGEX = /^(?!\/)[\w\-./]+(?<!\/)$/
|
||||
|
||||
/**
|
||||
* Custom remark plugin that detects file paths in inline code blocks
|
||||
* and marks them with metadata for later rendering
|
||||
* Custom remark plugin that marks potential file paths in inline code blocks
|
||||
* This is synchronous - actual file existence checking happens in the React component
|
||||
*/
|
||||
const remarkFilePathDetection = () => {
|
||||
return async (tree: Node) => {
|
||||
const fileNameRegex = /^(?!\/)[\w\-./]+(?<!\/)$/
|
||||
const _inlineCodeNodes: any[] = []
|
||||
const filePathPromises: Promise<void>[] = []
|
||||
|
||||
// Collect all inline code nodes that might be file paths
|
||||
const remarkMarkPotentialFilePaths = () => {
|
||||
return (tree: Node) => {
|
||||
visit(tree, "inlineCode", (node: Node & { value: string; data?: any }) => {
|
||||
if (fileNameRegex.test(node.value) && !node.value.includes("\n")) {
|
||||
const promise = FileServiceClient.ifFileExistsRelativePath(StringRequest.create({ value: node.value }))
|
||||
.then((exists) => {
|
||||
if (exists.value) {
|
||||
node.data = node.data || {}
|
||||
node.data.hProperties = node.data.hProperties || {}
|
||||
node.data.hProperties["data-is-file-path"] = "true"
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.debug(`Failed to check file existence for ${node.value}:`, err)
|
||||
})
|
||||
|
||||
filePathPromises.push(promise)
|
||||
if (FILE_PATH_REGEX.test(node.value) && !node.value.includes("\n")) {
|
||||
// Mark as potential file path - actual checking happens in React component
|
||||
node.data = node.data || {}
|
||||
node.data.hProperties = node.data.hProperties || {}
|
||||
node.data.hProperties["data-potential-file-path"] = "true"
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(filePathPromises)
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownBlock = memo(({ markdown, compact }: MarkdownBlockProps) => {
|
||||
/**
|
||||
* Component that renders inline code and checks if it's a valid file path asynchronously
|
||||
* Shows the code immediately, then adds the file link icon when confirmed
|
||||
*/
|
||||
const InlineCodeWithFileCheck: React.FC<ComponentProps<"code"> & { [key: string]: any }> = (props) => {
|
||||
const [isFilePath, setIsFilePath] = useState<boolean | null>(null)
|
||||
const filePath = typeof props.children === "string" ? props.children : String(props.children || "")
|
||||
const isPotentialFilePath = props["data-potential-file-path"] === "true"
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPotentialFilePath) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// Check file existence asynchronously
|
||||
FileServiceClient.ifFileExistsRelativePath(StringRequest.create({ value: filePath }))
|
||||
.then((exists) => {
|
||||
if (!cancelled) {
|
||||
setIsFilePath(exists.value)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.debug(`Failed to check file existence for ${filePath}:`, err)
|
||||
if (!cancelled) {
|
||||
setIsFilePath(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filePath, isPotentialFilePath])
|
||||
|
||||
// If confirmed as a file path, render as clickable button
|
||||
if (isFilePath) {
|
||||
return (
|
||||
<Button
|
||||
className="p-0 ml-0.5 mt-0.5 leading-none align-middle transition-opacity relative top-px text-preformat translate-y-[-2px] gap-0.5"
|
||||
onClick={() => FileServiceClient.openFileRelativePath({ value: filePath })}
|
||||
size="icon"
|
||||
title={`Open ${filePath} in editor`}
|
||||
type="button"
|
||||
variant="icon">
|
||||
<code {...props} />
|
||||
<SquareArrowOutUpRightIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise render as regular code (shows immediately, before file check completes)
|
||||
return <code {...props} />
|
||||
}
|
||||
|
||||
const MarkdownBlock = memo(({ markdown, compact, showCursor }: MarkdownBlockProps) => {
|
||||
const [reactContent, setMarkdown] = useRemark({
|
||||
remarkPlugins: [
|
||||
remarkPreventBoldFilenames,
|
||||
remarkUrlToLink,
|
||||
remarkHighlightActMode,
|
||||
remarkFilePathDetection,
|
||||
remarkMarkPotentialFilePaths,
|
||||
() => {
|
||||
return (tree) => {
|
||||
visit(tree, "code", (node: any) => {
|
||||
@@ -407,25 +354,8 @@ const MarkdownBlock = memo(({ markdown, compact }: MarkdownBlockProps) => {
|
||||
return <MermaidBlock code={codeText} />
|
||||
}
|
||||
|
||||
// Check if this is a file path (metadata is converted to data- attributes by rehype-react)
|
||||
if (props["data-is-file-path"]) {
|
||||
// Extract the file path from the code element's children
|
||||
const filePath = typeof props.children === "string" ? props.children : String(props.children || "")
|
||||
|
||||
return (
|
||||
<>
|
||||
<code {...props} />
|
||||
<button
|
||||
className="codicon codicon-link-external bg-transparent border-0 appearance-none p-0 ml-0.5 leading-none align-middle opacity-70 hover:opacity-100 transition-opacity text-[1em] relative top-px text-(--vscode-textPreformat-foreground) translate-y-[-2px]"
|
||||
onClick={() => FileServiceClient.openFileRelativePath({ value: filePath })}
|
||||
title={`Open ${filePath} in editor`}
|
||||
type="button"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <code {...props} />
|
||||
// Use the async file check component for potential file paths
|
||||
return <InlineCodeWithFileCheck {...props} />
|
||||
},
|
||||
strong: (props: ComponentProps<"strong">) => {
|
||||
// Check if this is an "Act Mode" strong element by looking for the keyboard shortcut
|
||||
@@ -460,10 +390,14 @@ const MarkdownBlock = memo(({ markdown, compact }: MarkdownBlockProps) => {
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledMarkdown className="ph-no-capture" compact={compact}>
|
||||
<div className="inline-markdown-block">
|
||||
<span
|
||||
className={cn("inline [&>p]:mt-0", {
|
||||
"inline-cursor-container": showCursor,
|
||||
"[&>p]:m-0": compact,
|
||||
})}>
|
||||
{reactContent}
|
||||
</StyledMarkdown>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer [&_svg]:size-2",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer [&_svg]:size-2 overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -16,10 +16,12 @@ const buttonVariants = cva(
|
||||
"outline-primary":
|
||||
"!bg-transparent !border-[var(--vscode-button-background)] !border-[1px] !border-solid !text-[var(--vscode-button-background)] !hover:bg-[color-mix(in_srgb,var(--vscode-button-background)_15%,transparent)] !active:bg-[color-mix(in_srgb,var(--vscode-button-background)_25%,transparent)]",
|
||||
ghost: "hover:bg-accent/10",
|
||||
link: "text-link underline-offset-4 hover:underline p-0 m-0 cursor-text select-text",
|
||||
link: "text-link underline-offset-4 hover:underline p-0 m-0 cursor-text select-text hover:text-link-hover",
|
||||
text: "text-foreground cursor-text select-text p-0 m-0",
|
||||
icon: "hover:opacity-80 p-0 m-0 border-0 cursor-pointer hover:shadow-none focus:ring-0 focus:ring-offset-0",
|
||||
cline: "bg-cline border-foreground/20 text-cline-foreground",
|
||||
success:
|
||||
"bg-success/10 text-success border-[#176f2c] text-white hover:bg-[#197f31] hover:border-[#197f31] active:bg-[#156528] active:border-[#156528] hover:text-white",
|
||||
danger: "bg-[#c42b2b] border-[#c42b2b]! text-white! hover:bg-[#a82424]! hover:border-[#a82424]! active:bg-[#8f1f1f]! active:border-[#8f1f1f]!",
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
pre {
|
||||
@apply bg-code;
|
||||
}
|
||||
code {
|
||||
@apply bg-code;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
+124
-4
@@ -9,9 +9,18 @@
|
||||
--color-code: var(--vscode-editor-background);
|
||||
--color-code-foreground: var(--vscode-editor-foreground);
|
||||
--color-code-border: var(--vscode-editor-border);
|
||||
--color-code-block-background: var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30));
|
||||
--color-code-block-background: var(
|
||||
--vscode-textCodeBlock-background,
|
||||
--vscode-editor-background,
|
||||
--vscode-sideBar-background,
|
||||
rgb(30 30 30)
|
||||
);
|
||||
--color-quote: var(--vscode-textBlockQuote-background);
|
||||
--color-quote-foreground: var(--vscode-textBlockQuote-foreground);
|
||||
--color-text-block-background: var(--vscode-textCodeBlock-background);
|
||||
--color-editor-group-border: var(--vscode-editorGroup-border);
|
||||
--color-editor-widget-border: var(--vscode-editorWidget-border, #cccccc);
|
||||
--color-editor-warning-foreground: var(--vscode-editorWarning-foreground);
|
||||
--color-sidebar-background: var(--vscode-sideBar-background);
|
||||
--color-sidebar-foreground: var(--vscode-sideBar-foreground);
|
||||
--color-input-foreground: var(--vscode-input-foreground);
|
||||
@@ -22,10 +31,12 @@
|
||||
--color-input-error-foreground: var(--vscode-inputValidation-errorForeground, #8f1f1f);
|
||||
--color-selection: var(--vscode-list-activeSelectionBackground);
|
||||
--color-selection-foreground: var(--vscode-list-activeSelectionForeground);
|
||||
--color-selection-inactive: var(--vscode-editor-inactiveSelectionBackground);
|
||||
--color-button-background: var(--vscode-button-background);
|
||||
--color-button-hover: var(--vscode-button-hoverBackground);
|
||||
--color-button-foreground: var(--vscode-button-foreground);
|
||||
--color-button-separator: var(--vscode-button-separator);
|
||||
--color-text-separator: var(--vscode-textSeparator-foreground, #424242);
|
||||
--color-button-secondary-background: var(--vscode-button-secondaryBackground);
|
||||
--color-button-secondary-background-hover: var(--vscode-button-secondaryHoverBackground);
|
||||
--color-button-secondary-foreground: var(--vscode-button-secondaryForeground);
|
||||
@@ -35,6 +46,7 @@
|
||||
--color-menu-foreground: var(--vscode-menu-foreground);
|
||||
--color-menu-border: var(--vscode-menu-border);
|
||||
--color-menu-shadow: var(--vscode-menu-shadow);
|
||||
--color-preformat: var(--vscode-textPreformat-foreground, #f78383);
|
||||
--color-link: var(--vscode-textLink-foreground);
|
||||
--color-link-hover: var(--vscode-textLink-activeForeground);
|
||||
--color-list-hover: var(--vscode-list-hoverBackground);
|
||||
@@ -44,26 +56,38 @@
|
||||
--color-banner-foreground: var(--vscode-banner-foreground);
|
||||
--color-banner-icon: var(--vscode-banner-iconForeground);
|
||||
--color-icon-foreground: var(--vscode-icon-foreground);
|
||||
--color-failed-icon: var(--vscode-testing-iconFailed);
|
||||
--color-notification-foreground: var(--vscode-notificationsInfoIcon-foreground);
|
||||
--color-toolbar-default: var(--vscode-toolbar-background);
|
||||
--color-toolbar-hover: var(--vscode-toolbar-hoverBackground);
|
||||
--color-diff-added: var(--vscode-diffEditor-insertedTextBackground);
|
||||
--color-diff-removed: var(--vscode-diffEditor-removedTextBackground);
|
||||
--color-error: var(--vscode-errorForeground);
|
||||
--color-error-icon: var(--vscode-problemsErrorIcon-foreground);
|
||||
--color-failed-icon: var(--vscode-testing-iconFailed);
|
||||
--color-error: var(--vscode-errorForeground, #c42b2b);
|
||||
--color-error-icon: var(--vscode-problemsErrorIcon-foreground, #c42b2b);
|
||||
--color-description: var(--vscode-descriptionForeground);
|
||||
--color-success: var(--vscode-charts-green);
|
||||
--color-warning: var(--vscode-charts-yellow);
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-base:
|
||||
var(--vscode-font-family), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Open Sans", "Helvetica Neue", sans-serif;
|
||||
--font-mono:
|
||||
var(--vscode-editor-font-family), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
--font-azeret-mono: Azeret Mono, monospace;
|
||||
--font-editor: var(--vscode-editor-font-family);
|
||||
--text-editor-size: var(--vscode-editor-font-size);
|
||||
--text-2xl: calc(2.25 * var(--vscode-font-size));
|
||||
--text-xl: calc(2 * var(--vscode-font-size));
|
||||
--text-lg: calc(1.5 * var(--vscode-font-size));
|
||||
--text-md: calc(1.25 * var(--vscode-font-size));
|
||||
--text-base: var(--vscode-font-size);
|
||||
--text-base: var(--vscode-font-size, 14px);
|
||||
--text-sm: calc(0.95 * var(--vscode-font-size));
|
||||
--text-xs: calc(0.85 * var(--vscode-font-size));
|
||||
--text-xxs: calc(0.5 * var(--vscode-font-size));
|
||||
--breakpoint-xs: 400px;
|
||||
--radius-half: 50%;
|
||||
--radius-xs: 2px;
|
||||
--breakpoint-xxs: 180px;
|
||||
|
||||
@@ -78,6 +102,50 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-cursor-blink: cursorBlink 1s ease-in-out infinite;
|
||||
@keyframes cursorBlink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-icon-pulse: iconPulse 1s ease-in-out infinite;
|
||||
@keyframes iconPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-shimmer: shimmer 5s infinite linear;
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-fade-slide-in: fadeSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -219,4 +287,56 @@
|
||||
.animate-flash {
|
||||
animation: flash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.inline-markdown-block {
|
||||
@apply text-base text-foreground font-base;
|
||||
|
||||
pre {
|
||||
@apply bg-code rounded-xs overflow-x-auto overflow-y-hidden;
|
||||
margin: 13px 0;
|
||||
padding: 10px 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
padding-right: 70px;
|
||||
}
|
||||
|
||||
pre > code .hljs-deletion {
|
||||
@apply inline-block w-full bg-diff-removed;
|
||||
}
|
||||
|
||||
pre > code .hljs-addition {
|
||||
@apply inline-block w-full bg-diff-added;
|
||||
}
|
||||
|
||||
code span.line:empty {
|
||||
@apply hidden;
|
||||
}
|
||||
code {
|
||||
@apply break-words rounded-xs;
|
||||
}
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
@apply leading-tight;
|
||||
}
|
||||
hr,
|
||||
ul,
|
||||
ol {
|
||||
@apply my-3 ml-5;
|
||||
}
|
||||
li > ul,
|
||||
li > ol {
|
||||
@apply my-1 ml-2;
|
||||
}
|
||||
|
||||
code:not(pre > code) {
|
||||
@apply font-mono text-preformat bg-code-block-background border border-text-separator rounded-xs py-0.5 whitespace-pre-line break-words text-editor-size;
|
||||
}
|
||||
|
||||
/* Inline cursor container - adds blinking cursor after last child */
|
||||
.inline-cursor-container > :last-child::after {
|
||||
content: "";
|
||||
@apply inline-block w-0.5 h-[1em] ml-0.5 bg-foreground align-text-bottom animate-cursor-blink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user