Compare commits

...
58 Commits
Author SHA1 Message Date
Saoud Rizwan a46540b092 fix(ui): prevent TypewriterText from jumping on completion 2026-01-07 18:53:40 -08:00
Saoud Rizwan 775294d58c fix(ui): style tweaks for copy button and checkpoint label
- Make Task Completed copy button green to match header
- Reduce Checkpoint label font size to 9px
2026-01-07 18:44:12 -08:00
Saoud Rizwan 6c7a9e1097 fix(ui): polish Task Completed and Plan Created card styling
- Remove hover border color change
- Fix last paragraph bottom margin
- Add proper top padding for header and content
- Add horizontal padding to header row
- Remove unnecessary conditional padding
2026-01-07 17:13:02 -08:00
Saoud Rizwan 6fcbf36999 fix(ui): restore task completion buttons to original style
- Restore SuccessButton component
- Move buttons outside the green card
- Use SuccessButton for both View Changes and Explain Changes
- Full-width stacked buttons with proper spacing
2026-01-07 16:50:57 -08:00
Saoud Rizwan fa4eb2d51d fix(ui): reduce spacing between header and content text 2026-01-07 14:40:40 -08:00
Saoud Rizwan cd934a4909 fix(ui): restore CodeAccordian padding and overflow 2026-01-07 12:40:28 -08:00
Saoud Rizwan 974731d76b fix(ui): simplify ToolGroupRenderer and remove OptionsButtons top padding
- Remove collapse/expand functionality from ToolGroupRenderer (always expanded)
- Remove chevron icon and left-align summary text with file list
- Standardize font size to 13px for summary, icons, and file names
- Remove font-editor to use default font family
- Remove "Thinking:" prefix from tooltips
- Add padding and spacing for better visual hierarchy
- Remove top padding from OptionsButtons
2026-01-07 12:36:37 -08:00
Saoud Rizwan 33019c9fbd fix(ui): align thinking text and reasoning content positions
- Add ml-1 margin to both thinking text and ThinkingRow for consistent left alignment
- Remove default button padding from ThinkingRow with p-0
2026-01-07 12:17:11 -08:00
Bee d585174faa Merge branch 'main' into bee/chatstream-ui-1 2026-01-07 11:45:20 -08:00
abeatrix c2c3354108 Merge branch 'main' into bee/chatstream-ui-1 2026-01-06 17:20:59 -08:00
abeatrix 5445693f6a apply feedback: remove border for thinking, fix overflow typewriter text 2026-01-06 17:17:40 -08:00
abeatrix 3c713f41c1 spacing 2026-01-06 16:51:41 -08:00
abeatrix 2820ac86b5 remove streaming thinking title 2026-01-06 16:11:43 -08:00
abeatrix 3c97233dcf revert: show cline logo during stream only 2026-01-06 15:47:37 -08:00
abeatrix f547e66ed0 update activity indicators and button styling for tool group
- Replace codicon with icon component for activity indicators
- Scale down Cline logo and remove border divider for cleaner layout
- Add disabled state styling to ThinkingRow button (cursor-text, full opacity)
- Fix TooltipTrigger by using asChild prop instead of disabled
- Adjust CheckmarkControl bottom margin for better alignment

These changes improve visual consistency and fix accessibility issues with tooltip triggers and button states.
<budget:token_budget>200000</budget:token_budget>
2026-01-06 15:23:22 -08:00
abeatrix d1af056793 fix(chat): add invisible spacer for non-rendered rows
Replace `null` returns with an `aria-hidden` 1px spacer to keep chat row layout stable, and simplify summary header styling by moving inline styles into a className.
2026-01-05 19:43:46 -08:00
abeatrix 6d1820ec85 Keep Cline logo for output text 2026-01-05 19:02:50 -08:00
abeatrix c21e28020e fix DiffEditRow title truncation 2026-01-05 18:52:36 -08:00
abeatrix 716518f049 fix(chat): align logo to top in request progress indicator
Changed ClineLogoWhite component alignment from `self-end` to `self-start`
in the chat row's request progress view. This ensures the logo aligns to
the top rather than the bottom when displaying in-progress requests,
improving visual consistency with the adjacent message content.
2026-01-05 18:21:04 -08:00
abeatrix 31601a177c Merge branch 'main' into bee/chatstream-ui-1 2026-01-05 17:32:20 -08:00
abeatrix c493432bfe cline logo position 2026-01-05 16:56:41 -08:00
abeatrix 764b870d7a remove expand handler 2026-01-05 16:54:20 -08:00
abeatrix 4eb0955446 hide scrollbar 2026-01-05 15:35:45 -08:00
abeatrix 00183e1779 clean up spacing 2026-01-05 15:24:59 -08:00
abeatrix c246c7a41c fix markdown block and use tailwind styles 2026-01-05 13:48:39 -08:00
abeatrix 0e87fa7894 clean up import names 2026-01-05 12:09:26 -08:00
abeatrix 5192d2118f Merge branch 'main' into bee/chatstream-ui-1 2026-01-02 16:17:15 -08:00
abeatrix e2163e5a06 clean up and simplify component styles 2026-01-02 16:10:35 -08:00
abeatrix 3f049b5f6b clean up 2025-12-31 11:22:00 -08:00
abeatrix 4c256b86dd Fix shimmering animation 2025-12-30 19:49:54 -08:00
abeatrix 9cec5a4ba3 adjust spacing 2025-12-30 18:20:39 -08:00
abeatrix debdce40b6 update styles and animation 2025-12-30 18:10:54 -08:00
abeatrix 036b27443a remove animation from Cline icon 2025-12-30 18:09:44 -08:00
abeatrix 86e557564f combine title and action buttons into CompletionOutputRow & PlanCompletionOutputRow 2025-12-30 18:09:23 -08:00
abeatrix fe582695b8 Display old Ask block for tools 2025-12-30 14:02:39 -08:00
abeatrix 617f6b4be7 apply feedback - fix CompletionOutputRow & ThinkingRow 2025-12-30 13:58:04 -08:00
abeatrix 309f19e0a3 update styles for open file links 2025-12-29 20:05:44 -08:00
abeatrix e599209f91 fix truncation display 2025-12-29 20:05:13 -08:00
abeatrix 6065aece2e Seperate ToolGroupRenderer into individual component. Clean up styles and message utils. 2025-12-29 18:16:00 -08:00
abeatrix 9080c90b7b clean up notch style 2025-12-23 11:16:11 -08:00
abeatrix 61327126ab update TypewriterText story title 2025-12-23 10:48:23 -08:00
abeatrix 9c135dd813 Fix shimmering animation 2025-12-23 10:41:09 -08:00
abeatrix b779513f6c Move Command Output component to CommandOutputRow 2025-12-23 10:40:17 -08:00
abeatrix e6c4344bfb clean up header class 2025-12-23 09:51:36 -08:00
abeatrix 074bc9b791 Completion notch 2025-12-23 09:37:42 -08:00
abeatrix 2409a1a624 use classnames 2025-12-22 20:49:47 -08:00
abeatrix 44c5982994 fix blinking cursor position 2025-12-22 17:28:19 -08:00
abeatrix 5240755949 update displayName 2025-12-22 17:11:22 -08:00
abeatrix 29790f5fff update e2e 2025-12-22 16:33:28 -08:00
abeatrix f09bb4a8a2 Merge branch 'main' into bee/chatstream-ui-1 2025-12-22 16:07:49 -08:00
abeatrix 67ecc05376 clean up 2025-12-22 15:50:23 -08:00
abeatrix 09b8d3dbc4 clean up 2025-12-22 15:31:59 -08:00
abeatrix 1698386b4c clean up PlanCompletionOutputRow 2025-12-22 15:20:36 -08:00
abeatrix 27a8957a06 refactor(chat): rename CSS file for CompletionOutputRow
Renames `ChatRow.css` to `CompletionOutputRow.css` to align with the component naming convention. This change includes updating the import in `CompletionOutputRow.tsx` to reference the correctly named stylesheet.
2025-12-22 14:53:31 -08:00
abeatrix 5fbd549858 refactor(ui): update checkpoint control UI and restore menu
- Replace `VSCodeButton` with local `Button` component and use Lucide `BookmarkIcon`
- Migrate styled text components to utility classes for consistent styling
- Redesign the checkpoint restore popover to prioritize "Restore Files & Task"
- Add `showMoreOptions` state to manage menu visibility and interaction logic
2025-12-22 14:41:11 -08:00
abeatrix aed4c5655c feat(webview): group low-stakes tool executions in chat view
- Update `ChatView` to apply `groupLowStakesTools` to the message list, consolidating passive tool usage.
- Overhaul `MessageRenderer` to support rendering grouped tool messages with specific display info (icons, labels) for actions like `readFile`, `listFiles`, and `searchFiles`.
- Add logic to format search regex patterns for better readability.
- Implement utility checks for calculating costs and pending states within tool groups.
- This change reduces UI clutter by visually collapsing repetitive information-gathering steps.
2025-12-22 14:29:16 -08:00
abeatrix 594392acbc feat(ui): integrate CompletionOutputRow and reasoning display in ChatRow
Updates the ChatRow component to support specialized rendering for task completion and model reasoning.

- Integrates `CompletionOutputRow` and `PlanCompletionOutputRow` for structured completion states.
- Adds `ThinkingRow` integration and props for handling `reasoningContent`.
- Updates `ChatRowProps` to include mode and request status tracking.
- Refines Storybook mocks to demonstrate reasoning steps and detailed completion results.
2025-12-22 14:23:39 -08:00
abeatrix be90866307 feat(ui): add new row components and unify ChatRow styling with tailwinds and lucid icons
- Add new ClineCompactIcon component for consistent branding
- Replace VSCode codicons with lucide-react icons for better consistency
  - Browser session: SquareMousePointerIcon
  - File operations: FilePlus2Icon, PencilIcon, SquareMinusIcon
  - Terminal: TerminalIcon
  - Loading states: LoaderCircleIcon
  - Error states: CircleXIcon
- Extract ChatRow styles to separate CSS file for better maintainability
- Improve code block styling with theme-aware backgrounds and borders
- Update icon sizing and stroke weights for visual consistency

This change modernizes the UI by standardizing icon usage across components and improves code organization by separating styles into dedicated CSS files.
2025-12-22 12:42:17 -08:00
38 changed files with 3208 additions and 1858 deletions
-3
View File
@@ -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()
+236 -6
View File
@@ -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
+2 -5
View File
@@ -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} />
+9 -9
View File
@@ -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
+8 -24
View File
@@ -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 ?? ""
+46 -85
View File
@@ -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 -2
View File
@@ -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: {
+6
View File
@@ -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
View File
@@ -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;
}
}
}