Show LLM streaming file write content (#3241)

This commit is contained in:
Chris Estreich 2025-05-13 09:47:33 -07:00 committed by GitHub
parent 9a358ab0f5
commit d3c469391f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 130 additions and 177 deletions

View file

@ -31,6 +31,7 @@ export async function applyDiffTool(
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
diff: diffContent,
}
try {
@ -46,8 +47,10 @@ export async function applyDiffTool(
return
}
const partialMessage = JSON.stringify(sharedMessageProps)
await cline.ask("tool", partialMessage, block.partial, toolProgressStatus).catch(() => {})
await cline
.ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus)
.catch(() => {})
return
} else {
if (!relPath) {

View file

@ -26,13 +26,13 @@ export async function insertContentTool(
const sharedMessageProps: ClineSayTool = {
tool: "insertContent",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
diff: content,
lineNumber: line ? parseInt(line, 10) : undefined,
}
try {
if (block.partial) {
const partialMessage = JSON.stringify(sharedMessageProps)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
await cline.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
return
}
@ -145,14 +145,15 @@ export async function insertContentTool(
return
}
const userFeedbackDiff = JSON.stringify({
tool: "insertContent",
path: getReadablePath(cline.cwd, relPath),
lineNumber: lineNumber,
diff: userEdits,
} satisfies ClineSayTool)
await cline.say("user_feedback_diff", userFeedbackDiff)
await cline.say(
"user_feedback_diff",
JSON.stringify({
tool: "insertContent",
path: getReadablePath(cline.cwd, relPath),
diff: userEdits,
lineNumber: lineNumber,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +

View file

@ -226,13 +226,14 @@ export async function searchAndReplaceTool(
return
}
const userFeedbackDiff = JSON.stringify({
tool: "appliedDiff",
path: getReadablePath(cline.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool)
await cline.say("user_feedback_diff", userFeedbackDiff)
await cline.say(
"user_feedback_diff",
JSON.stringify({
tool: "appliedDiff",
path: getReadablePath(cline.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
// Format and send response with user's updates
const resultMessage = [

View file

@ -72,6 +72,7 @@ export async function writeToFileTool(
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
content: newContent,
isOutsideWorkspace,
}

View file

@ -12,10 +12,12 @@ import { useCopyToClipboard } from "@src/utils/clipboard"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
import { vscode } from "@src/utils/vscode"
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
import { Button } from "@src/components/ui"
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
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"
@ -287,10 +289,11 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content ?? tool.diff}
language={tool.tool === "appliedDiff" ? "diff" : undefined}
progressStatus={message.progressStatus}
isLoading={message.partial}
diff={tool.diff!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -312,10 +315,11 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
diff={tool.diff!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -333,10 +337,10 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.diff}
progressStatus={message.progressStatus}
isLoading={message.partial}
diff={tool.diff!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -350,9 +354,9 @@ export const ChatRowContent = ({
<span style={{ fontWeight: "bold" }}>{t("chat:fileOperations.wantsToCreate")}</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content}
isLoading={message.partial}
code={tool.content!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -371,47 +375,21 @@ export const ChatRowContent = ({
: t("chat:fileOperations.didRead")}
</span>
</div>
<div
style={{
borderRadius: 3,
backgroundColor: CODE_BLOCK_BG_COLOR,
overflow: "hidden",
border: "1px solid var(--vscode-editorGroup-border)",
}}>
<div
style={{
color: "var(--vscode-descriptionForeground)",
display: "flex",
alignItems: "center",
padding: "9px 10px",
cursor: "pointer",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
onClick={() => {
vscode.postMessage({ type: "openFile", text: tool.content })
}}>
<ToolUseBlock>
<ToolUseBlockHeader
onClick={() => vscode.postMessage({ type: "openFile", text: tool.content })}>
{tool.path?.startsWith(".") && <span>.</span>}
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
marginRight: "8px",
direction: "rtl",
textAlign: "left",
}}>
<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
{removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"}
{tool.reason}
</span>
<div style={{ flexGrow: 1 }}></div>
<span
className={`codicon codicon-link-external`}
style={{ fontSize: 13.5, margin: "1px 0" }}></span>
</div>
</div>
style={{ fontSize: 13.5, margin: "1px 0" }}
/>
</ToolUseBlockHeader>
</ToolUseBlock>
</>
)
case "fetchInstructions":
@ -422,8 +400,8 @@ export const ChatRowContent = ({
<span style={{ fontWeight: "bold" }}>{t("chat:instructions.wantsToFetch")}</span>
</div>
<CodeAccordian
code={tool.content}
isLoading={message.partial}
code={tool.content!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -441,8 +419,8 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
code={tool.content!}
path={tool.path!}
path={tool.path}
code={tool.content}
language="shell-session"
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
@ -461,8 +439,8 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
code={tool.content!}
path={tool.path!}
path={tool.path}
code={tool.content}
language="shell-session"
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
@ -481,8 +459,8 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
code={tool.content!}
path={tool.path!}
path={tool.path}
code={tool.content}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
@ -510,8 +488,8 @@ export const ChatRowContent = ({
</span>
</div>
<CodeAccordian
code={tool.content!}
path={tool.path! + (tool.filePattern ? `/(${tool.filePattern})` : "")}
code={tool.content}
language="log"
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
@ -881,13 +859,10 @@ export const ChatRowContent = ({
case "user_feedback_diff":
const tool = safeJsonParse<ClineSayTool>(message.text)
return (
<div
style={{
marginTop: -10,
width: "100%",
}}>
<div style={{ marginTop: -10, width: "100%" }}>
<CodeAccordian
diff={tool?.diff!}
code={tool?.diff}
language="diff"
isFeedback={true}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}

View file

@ -1,13 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { getIconForFilePath, getIconUrlByName, getIconForDirectoryPath } from "vscode-material-icons"
import { ModeConfig } from "@roo/shared/modes"
import {
ContextMenuOptionType,
ContextMenuQueryItem,
getContextMenuOptions,
SearchResult,
} from "@src/utils/context-mentions"
import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import { ModeConfig } from "@roo/shared/modes"
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
interface ContextMenuProps {
onSelect: (type: ContextMenuOptionType, value?: string) => void

View file

@ -1,109 +1,59 @@
import { memo, useMemo } from "react"
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
import { ToolProgressStatus } from "@roo/shared/ExtensionMessage"
import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
interface CodeAccordianProps {
code?: string
diff?: string
language?: string | undefined
path?: string
isFeedback?: boolean
isConsoleLogs?: boolean
isExpanded: boolean
onToggleExpand: () => void
isLoading?: boolean
progressStatus?: ToolProgressStatus
}
import { type ToolProgressStatus } from "@roo/shared/ExtensionMessage"
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
/*
We need to remove certain leading characters from the path in order for our leading ellipses trick to work.
However, we want to preserve all language characters (including CJK, Cyrillic, etc.) and only remove specific
punctuation that might interfere with the ellipsis display.
*/
export const removeLeadingNonAlphanumeric = (path: string): string => {
// Only remove specific punctuation characters that might interfere with ellipsis display
// Keep all language characters (including CJK, Cyrillic, etc.) and numbers
return path.replace(/^[/\\:*?"<>|]+/, "")
import { ToolUseBlock, ToolUseBlockHeader } from "./ToolUseBlock"
import CodeBlock from "./CodeBlock"
interface CodeAccordianProps {
path?: string
code?: string
language?: string | undefined
progressStatus?: ToolProgressStatus
isLoading?: boolean
isExpanded: boolean
isFeedback?: boolean
onToggleExpand: () => void
}
const CodeAccordian = ({
code,
diff,
language,
path,
isFeedback,
isConsoleLogs,
isExpanded,
onToggleExpand,
isLoading,
code = "",
language,
progressStatus,
isLoading,
isExpanded,
isFeedback,
onToggleExpand,
}: CodeAccordianProps) => {
const inferredLanguage = useMemo(
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
[path, language, code],
)
const inferredLanguage = useMemo(() => language ?? (path ? getLanguageFromPath(path) : undefined), [path, language])
const source = useMemo(() => code.trim(), [code])
const hasHeader = Boolean(path || isFeedback)
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)",
}}>
{(path || isFeedback || isConsoleLogs) && (
<div
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",
}}
className={`${isLoading ? "animate-pulse" : ""}`}
onClick={isLoading ? undefined : onToggleExpand}>
<ToolUseBlock>
{hasHeader && (
<ToolUseBlockHeader onClick={onToggleExpand}>
{isLoading && <VSCodeProgressRing className="size-3 mr-2" />}
{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",
}}>
{isFeedback ? (
<div className="flex items-center">
<span className={`codicon codicon-${isFeedback ? "feedback" : "codicon-output"} mr-1.5`} />
<span className="whitespace-nowrap overflow-hidden text-ellipsis mr-2 rtl">
{isFeedback ? "User Edits" : "Console Logs"}
</span>
</div>
) : (
<>
{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",
}}>
<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
{removeLeadingNonAlphanumeric(path ?? "") + "\u200E"}
</span>
</>
)}
<div style={{ flexGrow: 1 }}></div>
<div className="flex-grow-1" />
{progressStatus && progressStatus.text && (
<>
{progressStatus.icon && <span className={`codicon codicon-${progressStatus.icon} mr-1`} />}
@ -113,24 +63,17 @@ const CodeAccordian = ({
</>
)}
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</ToolUseBlockHeader>
)}
{(!hasHeader || isExpanded) && (
<div className="overflow-x-auto overflow-y-hidden max-w-full">
<CodeBlock source={source} language={inferredLanguage} />
</div>
)}
{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
<div
style={{
overflowX: "auto",
overflowY: "hidden",
maxWidth: "100%",
}}>
<CodeBlock
source={(code ?? diff ?? "").trim()}
language={diff !== undefined ? "diff" : inferredLanguage}
/>
</div>
)}
</div>
</ToolUseBlock>
)
}
// memo does shallow comparison of props, so if you need it to re-render when a nested object changes, you need to pass a custom comparison function
// Memo does shallow comparison of props, so if you need it to re-render when a
// nested object changes, you need to pass a custom comparison function.
export default memo(CodeAccordian)

View file

@ -0,0 +1,17 @@
import { cn } from "@/lib/utils"
import { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
export const ToolUseBlock = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("overflow-hidden border border-vscode-border rounded-xs p-2 cursor-pointer", className)}
style={{
backgroundColor: CODE_BLOCK_BG_COLOR,
}}
{...props}
/>
)
export const ToolUseBlockHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center select-none text-vscode-descriptionForeground", className)} {...props} />
)

View file

@ -0,0 +1,10 @@
// We need to remove certain leading characters from the path in order for our
// leading ellipses trick to work.
// However, we want to preserve all language characters (including CJK,
// Cyrillic, etc.) and only remove specific punctuation that might interfere
// with the ellipsis display.
//
// Only remove specific punctuation characters that might interfere with
// ellipsis display. Keep all language characters (including CJK, Cyrillic
// etc.) and numbers.
export const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[/\\:*?"<>|]+/, "")