diff --git a/evals/pnpm-lock.yaml b/evals/pnpm-lock.yaml index e1a787cc184..0909e7a0c8d 100644 --- a/evals/pnpm-lock.yaml +++ b/evals/pnpm-lock.yaml @@ -1176,19 +1176,6 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} - '@radix-ui/react-alert-dialog@1.1.7': - resolution: {integrity: sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-arrow@1.1.2': resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} peerDependencies: @@ -5234,20 +5221,6 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-alert-dialog@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': - dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.12)(react@19.0.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.0.12)(react@19.0.0) - '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@radix-ui/react-slot': 1.2.0(@types/react@19.0.12)(react@19.0.0) - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - optionalDependencies: - '@types/react': 19.0.12 - '@types/react-dom': 19.0.4(@types/react@19.0.12) - '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts index b04d73d1d42..a020755253d 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts @@ -140,7 +140,7 @@ async function testTerminalCommand( processId: Promise.resolve(123), creationOptions: {}, exitStatus: undefined, - state: { isInteractedWith: true }, + state: { isInteractedWith: true, shell: "/bin/bash" }, dispose: jest.fn(), hide: jest.fn(), show: jest.fn(), diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.test.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.test.ts index 0a2c79c0b2b..73bcef32800 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.test.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.test.ts @@ -87,7 +87,7 @@ async function testCmdCommand( processId: Promise.resolve(123), creationOptions: {}, exitStatus: undefined, - state: { isInteractedWith: true }, + state: { isInteractedWith: true, shell: "C:\\Windows\\System32\\cmd.exe" }, dispose: jest.fn(), hide: jest.fn(), show: jest.fn(), diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.test.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.test.ts index 0c84646cc0c..f64135ee984 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.test.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.test.ts @@ -89,7 +89,7 @@ async function testPowerShellCommand( processId: Promise.resolve(123), creationOptions: {}, exitStatus: undefined, - state: { isInteractedWith: true }, + state: { isInteractedWith: true, shell: "pwsh" }, dispose: jest.fn(), hide: jest.fn(), show: jest.fn(), diff --git a/src/shared/combineApiRequests.ts b/src/shared/combineApiRequests.ts index 03b0dc6c520..b3e0c790e75 100644 --- a/src/shared/combineApiRequests.ts +++ b/src/shared/combineApiRequests.ts @@ -78,7 +78,11 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] { } } catch (e) {} - result[startIndex] = { ...startMessage, text: JSON.stringify({ ...startData, ...finishData }) } + result[startIndex] = { + ...startMessage, + text: JSON.stringify({ ...startData, ...finishData }), + partial: message.partial ?? false, + } // Propagate partial status from the finish message } } diff --git a/src/shared/combineCommandSequences.ts b/src/shared/combineCommandSequences.ts index dd171a77ece..4473c00d72c 100644 --- a/src/shared/combineCommandSequences.ts +++ b/src/shared/combineCommandSequences.ts @@ -56,7 +56,13 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[ j++ } - combinedCommands.push({ ...messages[i], text: combinedText }) + const lastIndex = j - 1 + const lastMessageInSequence = messages[lastIndex] + combinedCommands.push({ + ...messages[i], + text: combinedText, + partial: lastMessageInSequence?.partial ?? false, // Propagate partial status from the last message + }) // Move to the index just before the next command or end of array. i = j - 1 diff --git a/webview-ui/jest.config.cjs b/webview-ui/jest.config.cjs index 347fcb39c63..cdd9844920b 100644 --- a/webview-ui/jest.config.cjs +++ b/webview-ui/jest.config.cjs @@ -20,6 +20,9 @@ module.exports = { "^src/i18n/TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", "^\\.\\./TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", "^\\./TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", + '^react-markdown$': 'identity-obj-proxy', + '^remark-gfm$': 'identity-obj-proxy', + '^shiki$': 'identity-obj-proxy' }, reporters: [["jest-simple-dot-reporter", {}]], transformIgnorePatterns: [ diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index fc1c0171ff5..0bbe1d7bc77 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -5944,7 +5944,7 @@ "@shikijs/types": "3.4.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" + "hast-util-to-html": "^9.0.4" } }, "node_modules/@shikijs/engine-javascript": { @@ -10303,6 +10303,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -12644,9 +12650,9 @@ } }, "node_modules/hast-util-to-html/node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", "funding": { "type": "github", diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 8912a5d80e0..40ad896ba13 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -18,7 +18,6 @@ import { Button } from "@src/components/ui" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import CodeAccordian from "../common/CodeAccordian" import CodeBlock from "../common/CodeBlock" -import MarkdownBlock from "../common/MarkdownBlock" import { ReasoningBlock } from "./ReasoningBlock" import Thumbnails from "../common/Thumbnails" import McpResourceRow from "../mcp/McpResourceRow" @@ -27,8 +26,8 @@ import McpToolRow from "../mcp/McpToolRow" import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" +import { Markdown } from "@/components/ui/markdown/Markdown" import { ProgressIndicator } from "./ProgressIndicator" -import { Markdown } from "./Markdown" import { CommandExecution } from "./CommandExecution" import { CommandExecutionError } from "./CommandExecutionError" @@ -80,6 +79,70 @@ const ChatRow = memo( export default ChatRow +// Define the new wrapper component with copy functionality +const MarkdownWithCopy = memo(({ content, partial }: { content: string; partial?: boolean }) => { + const [isHovering, setIsHovering] = useState(false) + // Assuming useCopyToClipboard is imported correctly (it is, line 5) + const { copyWithFeedback } = useCopyToClipboard(200) // Use shorter feedback duration like original + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + style={{ position: "relative" }}> + {/* Apply negative margins and text wrap styles */} +
+ +
+ {/* Conditional Copy Button */} + {content && !partial && isHovering && ( +
+ + { + const success = await copyWithFeedback(content) // Use content prop + if (success) { + const button = document.activeElement as HTMLElement + if (button) { + button.style.background = "var(--vscode-button-background)" + setTimeout(() => { + button.style.background = "" + }, 200) + } + } + }} + title="Copy as markdown"> + + +
+ )} +
+ ) +}) + export const ChatRowContent = ({ message, lastModifiedMessage, @@ -577,7 +640,7 @@ export const ChatRowContent = ({ {t("chat:subtasks.newTaskContent")}
- +
@@ -614,7 +677,7 @@ export const ChatRowContent = ({ {t("chat:subtasks.completionContent")}
- +
@@ -748,7 +811,7 @@ export const ChatRowContent = ({ padding: "12px 16px", backgroundColor: "var(--vscode-editor-background)", }}> - + @@ -830,7 +893,7 @@ export const ChatRowContent = ({ case "text": return (
- +
) case "user_feedback": @@ -889,7 +952,7 @@ export const ChatRowContent = ({ {title}
- +
) @@ -936,7 +999,7 @@ export const ChatRowContent = ({ )}
- +
) @@ -1055,7 +1118,7 @@ export const ChatRowContent = ({ {title}
- +
) @@ -1073,7 +1136,7 @@ export const ChatRowContent = ({ )}
{ - const [isHovering, setIsHovering] = useState(false) - - // Shorter feedback duration for copy button flash. - const { copyWithFeedback } = useCopyToClipboard(200) - - if (!markdown || markdown.length === 0) { - return null - } - - return ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - style={{ position: "relative" }}> -
- -
- {markdown && !partial && isHovering && ( -
- - { - const success = await copyWithFeedback(markdown) - if (success) { - const button = document.activeElement as HTMLElement - if (button) { - button.style.background = "var(--vscode-button-background)" - setTimeout(() => { - button.style.background = "" - }, 200) - } - } - }} - title="Copy as markdown"> - - -
- )} -
- ) -}) diff --git a/webview-ui/src/components/ui/markdown/CodeBlock.tsx b/webview-ui/src/components/ui/markdown/CodeBlock.tsx index 5342e6e7a68..726fa46a366 100644 --- a/webview-ui/src/components/ui/markdown/CodeBlock.tsx +++ b/webview-ui/src/components/ui/markdown/CodeBlock.tsx @@ -9,10 +9,13 @@ import { Button } from "@/components/ui" interface CodeBlockProps extends Omit, "children"> { language: string value: string + isComplete: boolean // Added isComplete prop } -export const CodeBlock: FC = memo(({ language, value, className, ...props }) => { +export const CodeBlock: FC = memo(({ language, value, className, isComplete, ...props }) => { + // Added isComplete to params const [highlightedCode, setHighlightedCode] = useState("") + // Removed isHighlighted state as it's redundant now const { isCopied, copy } = useClipboard() const onCopy = useCallback(() => { @@ -22,13 +25,27 @@ export const CodeBlock: FC = memo(({ language, value, className, }, [isCopied, copy, value]) useEffect(() => { + // Only highlight when the message stream is complete + if (!isComplete) { + setHighlightedCode("") // Clear highlighted code if streaming starts/continues + // Optionally clear old code: setHighlightedCode(""); + return // Don't highlight yet + } + + // If complete, proceed with highlighting + // This line calling the removed setIsHighlighted is now deleted. + const highlight = async () => { - const theme = "github-dark" // Use VSCode's current theme. + // Read body attribute to get VS Code theme kind + const vscodeThemeKind = document.body.dataset.vscodeThemeKind || "vscode-dark" // Default to dark if undefined + + // Select shiki theme based on VS Code theme kind + const shikiTheme = vscodeThemeKind === "vscode-light" ? "github-light" : "github-dark" try { const html = await codeToHtml(value, { lang: language, - theme, + theme: shikiTheme, // Use the dynamically determined theme transformers: [ { pre(node) { @@ -44,17 +61,54 @@ export const CodeBlock: FC = memo(({ language, value, className, }) setHighlightedCode(html) + // No need to set isHighlighted anymore } catch (e) { - setHighlightedCode(value) + // Log the initial highlighting failure + console.error(`Shiki highlighting failed for lang "${language}":`, e) + try { + // Attempt to highlight as plaintext as a fallback + const plaintextHtml = await codeToHtml(value, { + lang: "plaintext", // Force plaintext + theme: shikiTheme, + transformers: [ + // Keep the same transformers + { + pre(node) { + node.properties.class = cn(className, "overflow-x-auto") + return node + }, + code(node) { + node.properties.style = "background-color: transparent !important;" + return node + }, + }, + ], + }) + setHighlightedCode(plaintextHtml) // Set plaintext highlighted code + } catch (e2) { + // If plaintext highlighting also fails, log error and fall back to raw code placeholder + console.error("Shiki plaintext highlighting failed:", e2) + setHighlightedCode("") // Ensure placeholder is shown on double error + } } } highlight() - }, [language, value, className]) + }, [language, value, className, isComplete]) // Added isComplete to dependencies return (
-
+ {highlightedCode ? ( // Render based on whether highlightedCode has content + // Render highlighted code when ready +
+ ) : ( + // Render raw code placeholder while highlighting + // Apply className passed from Markdown.tsx and shiki transformer styles +
+					{value}
+				
+ )} + {/* Keep the copy button outside the conditional rendering */}
@@ -26,14 +37,14 @@ export function Markdown({ content }: { content: string }) { }, ol({ children }) { return ( -
    +
      {children}
    ) }, ul({ children }) { return ( -
      +
        {children}
      ) @@ -56,20 +67,76 @@ export function Markdown({ content }: { content: string }) { props.node?.position && props.node.position.start.line === props.node.position.end.line return isInline ? ( - + {children} ) : ( ) }, - a({ href, children }) { + table({ children }) { + // Use w-full for full width, border-collapse for clean borders, + // border and border-border for VS Code theme-aware borders, my-2 for margin + return {children}
      + }, + thead({ children }) { + // Add bottom border and a subtle background consistent with muted elements + return {children} + }, + tbody({ children }) { + // No specific styling needed for tbody itself + return {children} + }, + tr({ children }) { + // Add bottom border, hover effect, and remove border for the last row + return ( + + {children} + + ) + }, + th({ children }) { + // Add right border, padding, left alignment, medium font weight, muted text color, + // and remove border for the last header cell return ( - + + {children} + + ) + }, + strong({ children }) { + // Apply a slightly lighter font weight than default bold + return {children} + }, + td({ children }) { + // Add right border, padding, left alignment, and remove border for the last data cell + return ( + + {children} + + ) + }, + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => { + if (typeof href === "undefined") { + return {children} + } + + return ( + handleLinkClick(e, href)} // Call the helper function + > {children} ) @@ -79,3 +146,37 @@ export function Markdown({ content }: { content: string }) { ) } + +const handleLinkClick = (event: MouseEvent, href: string | undefined) => { + if (!href) { + return + } + + const isLocalPath = href.startsWith("file://") || href.startsWith("/") || !href.includes("://") + + if (!isLocalPath) { + // For non-local links, allow default behavior + return + } + + event.preventDefault() // Prevent default navigation for local links + + let filePath = href.replace("file://", "") + + const match = filePath.match(/(.*):(\d+)(-\d+)?$/) + let values = undefined + if (match) { + filePath = match[1] + values = { line: parseInt(match[2], 10) } + } + + if (!filePath.startsWith("/") && !filePath.startsWith("./")) { + filePath = "./" + filePath + } + + vscode.postMessage({ + type: "openFile", + text: filePath, + values, + }) +} diff --git a/webview-ui/src/components/ui/separator.tsx b/webview-ui/src/components/ui/separator.tsx index cf7c818536e..2f9baa608d9 100644 --- a/webview-ui/src/components/ui/separator.tsx +++ b/webview-ui/src/components/ui/separator.tsx @@ -12,7 +12,7 @@ const Separator = React.forwardRef< decorative={decorative} orientation={orientation} className={cn( - "shrink-0 bg-vscode-editor-background my-5", + "shrink-0 bg-border my-5", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className, )}