mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724f6553de | ||
|
|
b9ac3fab3b | ||
|
|
4a4020fbfa |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve read tool approval UX with accumulative file list. When manual approval is required, files now appear in a growing list with a dynamic header ("Cline read N files and wants to read:") instead of showing each file as a separate isolated block. The currently pending file is highlighted in blue and turns gray after approval.
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
SquareMinusIcon,
|
||||
TerminalIcon,
|
||||
TriangleAlertIcon,
|
||||
@@ -46,13 +45,14 @@ import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server
|
||||
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { UiServiceClient } from "@/services/grpc-client"
|
||||
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import CodeAccordian from "../common/CodeAccordian"
|
||||
import { CommandOutputContent, CommandOutputRow } from "./CommandOutputRow"
|
||||
import { CompletionOutputRow } from "./CompletionOutputRow"
|
||||
import { DiffEditRow } from "./DiffEditRow"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
import { FileToolRow } from "./FileToolRow"
|
||||
import HookMessage from "./HookMessage"
|
||||
import { MarkdownRow } from "./MarkdownRow"
|
||||
import NewTaskPreview from "./NewTaskPreview"
|
||||
@@ -113,7 +113,7 @@ const ChatRow = memo(
|
||||
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
|
||||
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
|
||||
// height starts off at Infinity
|
||||
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
|
||||
if (isLast && height !== 0 && height !== Number.POSITIVE_INFINITY && height !== prevHeightRef.current) {
|
||||
if (!isInitialRender) {
|
||||
onHeightChange(height > prevHeightRef.current)
|
||||
}
|
||||
@@ -421,7 +421,8 @@ export const ChatRowContent = memo(
|
||||
marginBottom: "-1.5px",
|
||||
transform: rotation ? `rotate(${rotation}deg)` : undefined,
|
||||
}}
|
||||
title={title}></span>
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
@@ -500,34 +501,16 @@ export const ChatRowContent = memo(
|
||||
case "readFile":
|
||||
const isImage = isImageFile(tool.path || "")
|
||||
return (
|
||||
<div>
|
||||
<div className={HEADER_CLASSNAMES}>
|
||||
{isImage ? <ImageUpIcon className="size-2" /> : <FileCode2Icon className="size-2" />}
|
||||
{tool.operationIsLocatedInWorkspace === false &&
|
||||
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
|
||||
<span className="font-bold">Cline wants to read this file:</span>
|
||||
</div>
|
||||
<div className="bg-code rounded-sm overflow-hidden border border-editor-group-border">
|
||||
<div
|
||||
className={cn("text-description flex items-center cursor-pointer select-none py-2 px-2.5", {
|
||||
"cursor-default select-text": isImage,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!isImage) {
|
||||
FileServiceClient.openFile(StringRequest.create({ value: tool.content })).catch(
|
||||
(err) => console.error("Failed to open file:", err),
|
||||
)
|
||||
}
|
||||
}}>
|
||||
{tool.path?.startsWith(".") && <span>.</span>}
|
||||
{tool.path && !tool.path.startsWith(".") && <span>/</span>}
|
||||
<span className="ph-no-capture whitespace-nowrap overflow-hidden text-ellipsis mr-2 text-left [direction: rtl]">
|
||||
{cleanPathPrefix(tool.path ?? "") + "\u200E"}
|
||||
</span>
|
||||
<div className="grow" />
|
||||
{!isImage && <SquareArrowOutUpRightIcon className="size-2" />}
|
||||
</div>
|
||||
<div className="ml-1 py-0.5">
|
||||
<div className="text-[13px] text-foreground mb-1">
|
||||
{message.type === "ask" ? "Cline wants to read this file:" : "Cline read this file:"}
|
||||
</div>
|
||||
<FileToolRow
|
||||
absolutePath={isImage ? undefined : tool.content}
|
||||
filePath={tool.path || ""}
|
||||
icon={isImage ? ImageUpIcon : FileCode2Icon}
|
||||
outsideWorkspace={tool.operationIsLocatedInWorkspace === false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "listFilesTopLevel":
|
||||
@@ -1158,10 +1141,9 @@ export const ChatRowContent = memo(
|
||||
text={text || ""}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// Virtuoso cannot handle zero-height items; render a spacer instead of null
|
||||
return <InvisibleSpacer />
|
||||
}
|
||||
// Virtuoso cannot handle zero-height items; render a spacer instead of null
|
||||
return <InvisibleSpacer />
|
||||
case "followup":
|
||||
let question: string | undefined
|
||||
let options: string[] | undefined
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { memo, useCallback } from "react"
|
||||
import { TypewriterText } from "@/components/chat/TypewriterText"
|
||||
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
export interface FileToolRowProps {
|
||||
/** The lucide icon component to display */
|
||||
icon: LucideIcon
|
||||
/** The file/folder path to display (relative/display path) */
|
||||
filePath: string
|
||||
/** Optional custom display text (overrides filePath display, e.g. for search results) */
|
||||
displayText?: string
|
||||
/** If true, renders a disabled row with typewriter activity text instead of clickable path */
|
||||
isActive?: boolean
|
||||
/** Activity text shown during active state (e.g. "Reading src/App.tsx...") */
|
||||
activityText?: string
|
||||
/** If true, the item is expandable (folders, search, definitions) rather than opening file */
|
||||
isExpandable?: boolean
|
||||
/** Whether the expandable item is currently expanded */
|
||||
isExpanded?: boolean
|
||||
/** Callback for expand/collapse toggle */
|
||||
onToggle?: () => void
|
||||
/** Expandable content (e.g., file list for folders) */
|
||||
expandedContent?: string | null
|
||||
/** Absolute path to use for opening file (if different from filePath) */
|
||||
absolutePath?: string
|
||||
/** Warning icon for outside-workspace files */
|
||||
outsideWorkspace?: boolean
|
||||
/** Highlight the file path in blue (e.g., for pending approval) */
|
||||
isHighlighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared compact file/folder row used in both ToolGroupRenderer (grouped tools)
|
||||
* and individual ChatRow tool cases (e.g., readFile with approval).
|
||||
*
|
||||
* Renders a single-line entry: [icon] [path/activity text]
|
||||
* Clicking opens the file in the editor or toggles expansion for folders.
|
||||
*/
|
||||
export const FileToolRow = memo<FileToolRowProps>(
|
||||
({
|
||||
icon: Icon,
|
||||
filePath,
|
||||
displayText,
|
||||
isActive,
|
||||
activityText,
|
||||
isExpandable,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
expandedContent,
|
||||
absolutePath,
|
||||
outsideWorkspace,
|
||||
isHighlighted,
|
||||
}) => {
|
||||
const handleOpenFile = useCallback(() => {
|
||||
if (absolutePath) {
|
||||
FileServiceClient.openFile(StringRequest.create({ value: absolutePath })).catch((err) =>
|
||||
console.error("Failed to open file:", err),
|
||||
)
|
||||
} else if (filePath) {
|
||||
FileServiceClient.openFileRelativePath(StringRequest.create({ value: filePath })).catch((err) =>
|
||||
console.error("Failed to open file:", err),
|
||||
)
|
||||
}
|
||||
}, [filePath, absolutePath])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isExpandable && onToggle) {
|
||||
onToggle()
|
||||
} else {
|
||||
handleOpenFile()
|
||||
}
|
||||
}, [isExpandable, onToggle, handleOpenFile])
|
||||
|
||||
// Active items render with "Reading..." TypewriterText (disabled, not clickable)
|
||||
if (isActive && activityText) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Button
|
||||
className="flex items-center gap-[3px] text-[13px] text-description py-[1px] min-w-0 max-w-full px-0 leading-tight -my-0.5"
|
||||
disabled
|
||||
size="icon"
|
||||
variant="text">
|
||||
<Icon className="opacity-70 shrink-0 size-[12px]" />
|
||||
<span className="flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left text-[13px]">
|
||||
<TypewriterText speed={15} text={activityText} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Completed/static items render as clickable
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-[3px] cursor-pointer text-[13px] py-[1px] hover:text-link min-w-0 max-w-full px-0 leading-tight -my-0.5",
|
||||
isHighlighted ? "text-link" : "text-description",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
size="icon"
|
||||
variant="text">
|
||||
<Icon className={cn("shrink-0 size-[12px]", isHighlighted ? "opacity-90" : "opacity-70")} />
|
||||
{outsideWorkspace && (
|
||||
<span
|
||||
className="codicon codicon-sign-out ph-no-capture"
|
||||
style={{
|
||||
color: "var(--vscode-editorWarning-foreground)",
|
||||
marginBottom: "-1.5px",
|
||||
transform: "rotate(-90deg)",
|
||||
}}
|
||||
title="This file is outside of your workspace"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left [direction:rtl] text-[13px]",
|
||||
{
|
||||
"[direction:ltr]": !!displayText,
|
||||
},
|
||||
)}>
|
||||
{(displayText || cleanPathPrefix(filePath)) + "\u200E"}
|
||||
</span>
|
||||
</Button>
|
||||
{/* Expanded content for folders/search/definitions - file lists only */}
|
||||
{isExpandable && isExpanded && expandedContent && (
|
||||
<pre className="m-1 ml-4 text-xs opacity-80 whitespace-pre-wrap break-words p-2 max-h-40 overflow-auto rounded-xs">
|
||||
{expandedContent}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions shared between ToolGroupRenderer and ChatRow
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get display info for a tool type (icon, path, label, displayText).
|
||||
*/
|
||||
export function getToolDisplayInfo(tool: ClineSayTool, getIcon: (toolName: string) => LucideIcon) {
|
||||
const icon = getIcon(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 activity text for active tool items (e.g., "Reading src/App.tsx...").
|
||||
*/
|
||||
export function getActivityText(tool: ClineSayTool): string | null {
|
||||
const cleanedPath = cleanPathPrefix(tool.path || "")
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
return tool.path ? `Reading ${cleanedPath}...` : null
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
return tool.path ? `Exploring ${cleanedPath}/...` : null
|
||||
case "searchFiles":
|
||||
return tool.regex && tool.path ? `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...` : null
|
||||
case "listCodeDefinitionNames":
|
||||
return tool.path ? `Analyzing ${cleanedPath}/...` : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search regex for compact display.
|
||||
*/
|
||||
function formatSearchDisplay(regex: string, path: string, filePattern?: string): string {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search regex for activity text.
|
||||
*/
|
||||
function formatSearchRegex(regex: string, path: string, filePattern?: string): string {
|
||||
const cleanedPath = cleanPathPrefix(path)
|
||||
const terms = regex
|
||||
.split("|")
|
||||
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
|
||||
.filter(Boolean)
|
||||
.join(" | ")
|
||||
return filePattern && filePattern !== "*" ? `"${terms}" in ${cleanedPath}/ (${filePattern})` : `"${terms}" in ${cleanedPath}/`
|
||||
}
|
||||
+186
-200
@@ -1,11 +1,7 @@
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
import { TypewriterText } from "@/components/chat/TypewriterText"
|
||||
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FileToolRow, getActivityText, getToolDisplayInfo } from "@/components/chat/FileToolRow"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import { getIconByToolName, getToolsNotInCurrentActivities, isLowStakesTool } from "../../utils/messageUtils"
|
||||
|
||||
interface ToolGroupRendererProps {
|
||||
@@ -20,40 +16,11 @@ interface ToolWithReasoning {
|
||||
reasoning?: string
|
||||
isActive?: boolean
|
||||
activityText?: string
|
||||
isPendingApproval?: boolean
|
||||
}
|
||||
|
||||
const EXPANDABLE_TOOLS = new Set(["listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"])
|
||||
|
||||
// Helper to format activity text for active items (from RequestStartRow logic)
|
||||
const getActivityText = (tool: ClineSayTool): string | null => {
|
||||
const cleanedPath = cleanPathPrefix(tool.path || "")
|
||||
const formatSearchRegex = (regex: string, path: string, filePattern?: string): string => {
|
||||
const cleanedPath = cleanPathPrefix(path)
|
||||
const terms = regex
|
||||
.split("|")
|
||||
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
|
||||
.filter(Boolean)
|
||||
.join(" | ")
|
||||
return filePattern && filePattern !== "*"
|
||||
? `"${terms}" in ${cleanedPath}/ (${filePattern})`
|
||||
: `"${terms}" in ${cleanedPath}/`
|
||||
}
|
||||
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
return tool.path ? `Reading ${cleanedPath}...` : null
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
return tool.path ? `Exploring ${cleanedPath}/...` : null
|
||||
case "searchFiles":
|
||||
return tool.regex && tool.path ? `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...` : null
|
||||
case "listCodeDefinitionNames":
|
||||
return tool.path ? `Analyzing ${cleanedPath}/...` : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate current activities (from RequestStartRow logic)
|
||||
const getCurrentActivities = (allMessages: ClineMessage[]): ClineMessage[] => {
|
||||
// Find current api_req
|
||||
@@ -84,10 +51,12 @@ const getCurrentActivities = (allMessages: ClineMessage[]): ClineMessage[] => {
|
||||
const msg = allMessages[i]
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
// Also skip pending approval tools that are not partial (they show in pending section)
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
}
|
||||
if (isLowStakesTool(msg)) {
|
||||
// Only show as "active" activity if still streaming (partial)
|
||||
if (isLowStakesTool(msg) && msg.partial) {
|
||||
activities.push(msg)
|
||||
}
|
||||
}
|
||||
@@ -97,12 +66,13 @@ const getCurrentActivities = (allMessages: ClineMessage[]): ClineMessage[] => {
|
||||
|
||||
/**
|
||||
* Renders a collapsible group of low-stakes tool calls.
|
||||
* Shows both completed tools AND currently active tools in a unified list (only for last group).
|
||||
* Shows completed tools, active tools, AND pending approval tools in a unified accumulative list.
|
||||
*/
|
||||
export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: ToolGroupRendererProps) => {
|
||||
const [expandedItems, setExpandedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Filter out tools in the "current activities" range (being shown in loading state)
|
||||
// This only filters ask tools that are still streaming - pending approval tools are kept
|
||||
const filteredMessages = useMemo(() => getToolsNotInCurrentActivities(messages, allMessages), [messages, allMessages])
|
||||
|
||||
// Get current activities (active reading/exploring) - only for last group
|
||||
@@ -113,10 +83,53 @@ export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: T
|
||||
return getCurrentActivities(allMessages)
|
||||
}, [allMessages, isLastGroup])
|
||||
|
||||
// Build completed tool items
|
||||
const completedTools = useMemo(() => buildToolsWithReasoning(filteredMessages), [filteredMessages])
|
||||
// Split messages into completed tools and pending approval tools
|
||||
// Use raw `messages` (not filtered) to find pending approval tools,
|
||||
// since getToolsNotInCurrentActivities filters out ask tools
|
||||
const { completedTools, pendingApprovalTools } = useMemo(() => {
|
||||
const completed: ToolWithReasoning[] = []
|
||||
const pending: ToolWithReasoning[] = []
|
||||
|
||||
// Build active tool items
|
||||
// First collect all completed (say) tool paths so we can exclude approved asks
|
||||
const completedPaths = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (isLowStakesTool(msg) && msg.type === "say") {
|
||||
const parsed = parseToolSafe(msg.text)
|
||||
if (parsed.path) completedPaths.add(parsed.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Find completed tools from filtered messages
|
||||
for (const msg of filteredMessages) {
|
||||
if (msg.say === "reasoning") continue
|
||||
if (isLowStakesTool(msg) && msg.type === "say") {
|
||||
completed.push({
|
||||
tool: msg,
|
||||
parsedTool: parseToolSafe(msg.text),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Collect ALL ask tools (approved or not) - they always stay in the list.
|
||||
// The blue highlight is handled at render time (only the current pending ask is blue).
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "reasoning") continue
|
||||
if (isLowStakesTool(msg) && msg.type === "ask" && !msg.partial) {
|
||||
const parsed = parseToolSafe(msg.text)
|
||||
if (!completedPaths.has(parsed.path || "")) {
|
||||
pending.push({
|
||||
tool: msg,
|
||||
parsedTool: parsed,
|
||||
isPendingApproval: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { completedTools: completed, pendingApprovalTools: pending }
|
||||
}, [messages, filteredMessages])
|
||||
|
||||
// Build active tool items (still streaming)
|
||||
const activeTools = useMemo(() => {
|
||||
return currentActivities
|
||||
.map((msg) => {
|
||||
@@ -132,130 +145,157 @@ export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: T
|
||||
.filter((item) => item.activityText)
|
||||
}, [currentActivities])
|
||||
|
||||
// Merge: completed items first, then active items (active only added to last group)
|
||||
// Deduplicate - exclude completed items that match active items by path
|
||||
const allTools = useMemo(() => {
|
||||
// Get paths of active items
|
||||
// Deduplicate - exclude completed items that match active or pending items by path
|
||||
const dedupedCompleted = useMemo(() => {
|
||||
const activePaths = new Set(activeTools.map((item) => item.parsedTool.path).filter(Boolean))
|
||||
const pendingPaths = new Set(pendingApprovalTools.map((item) => item.parsedTool.path).filter(Boolean))
|
||||
return completedTools.filter((item) => !activePaths.has(item.parsedTool.path) && !pendingPaths.has(item.parsedTool.path))
|
||||
}, [completedTools, activeTools, pendingApprovalTools])
|
||||
|
||||
// Filter out completed items that are also being actively read
|
||||
const dedupedCompleted = completedTools.filter((item) => !activePaths.has(item.parsedTool.path))
|
||||
const completedCount = dedupedCompleted.length
|
||||
const hasPendingApproval = pendingApprovalTools.length > 0
|
||||
const hasCompleted = completedCount > 0
|
||||
const hasActive = activeTools.length > 0
|
||||
|
||||
return [...dedupedCompleted, ...activeTools]
|
||||
}, [completedTools, activeTools])
|
||||
|
||||
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 summary = getToolGroupSummary(dedupedCompleted)
|
||||
|
||||
const handleItemToggle = useCallback((ts: number) => {
|
||||
setExpandedItems((prev) => ({ ...prev, [ts]: !prev[ts] }))
|
||||
}, [])
|
||||
|
||||
// Don't render if no tools to show
|
||||
if (allTools.length === 0) {
|
||||
// Don't render if no tools to show at all
|
||||
if (!hasCompleted && !hasPendingApproval && !hasActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Determine which pending tool is the current ask (blue highlighted)
|
||||
// Only the last ask tool in the group AND only if it's still the last message
|
||||
// in the conversation (nothing has happened after it — truly awaiting approval)
|
||||
const currentPendingTs = useMemo(() => {
|
||||
if (!isLastGroup || !hasPendingApproval || allMessages.length === 0) return null
|
||||
// Find the last ask tool in the group
|
||||
let lastAskTs: number | null = null
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (isLowStakesTool(msg) && msg.type === "ask" && !msg.partial) {
|
||||
lastAskTs = msg.ts
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastAskTs === null) return null
|
||||
// Verify this ask is still the last message in the conversation
|
||||
// If the conversation has moved on (new messages after it), it's been answered
|
||||
const lastConversationTs = allMessages[allMessages.length - 1].ts
|
||||
if (lastConversationTs !== lastAskTs) return null
|
||||
return lastAskTs
|
||||
}, [isLastGroup, hasPendingApproval, messages, allMessages])
|
||||
|
||||
// Count non-blue pending tools as "optimistically read" — they were already approved
|
||||
// but manual approval doesn't create say messages, so we count them here
|
||||
const optimisticReadCount = useMemo(() => {
|
||||
if (!hasPendingApproval) return 0
|
||||
return pendingApprovalTools.filter(({ tool }) => tool.ts !== currentPendingTs).length
|
||||
}, [pendingApprovalTools, currentPendingTs, hasPendingApproval])
|
||||
|
||||
const totalReadCount = completedCount + optimisticReadCount
|
||||
const hasAnyRead = totalReadCount > 0
|
||||
const hasCurrentPending = currentPendingTs !== null && pendingApprovalTools.some(({ tool }) => tool.ts === currentPendingTs)
|
||||
|
||||
// Build combined header
|
||||
const combinedHeader = useMemo(() => {
|
||||
if (hasAnyRead && hasCurrentPending) {
|
||||
return `Cline read ${totalReadCount} file${totalReadCount > 1 ? "s" : ""} and wants to read:`
|
||||
}
|
||||
if (hasAnyRead) {
|
||||
// All files done — use totalReadCount which includes optimistic reads from ask tools
|
||||
return `Cline read ${totalReadCount} file${totalReadCount > 1 ? "s" : ""}:`
|
||||
}
|
||||
if (hasPendingApproval) {
|
||||
return "Cline wants to read:"
|
||||
}
|
||||
return null
|
||||
}, [hasAnyRead, hasCurrentPending, totalReadCount, hasPendingApproval])
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2 ml-1 text-description")}>
|
||||
{/* Header */}
|
||||
<div className="text-[13px] text-foreground mb-1">{summary}:</div>
|
||||
{/* Combined header */}
|
||||
{combinedHeader && <div className="text-[13px] text-foreground mb-1">{combinedHeader}</div>}
|
||||
|
||||
{/* Content - unified list of completed + active tools */}
|
||||
<div className="min-w-0">
|
||||
{allTools.map(({ tool, parsedTool, isActive, activityText }) => {
|
||||
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
|
||||
|
||||
// Active items render with "Reading..." TypewriterText (match completed item structure exactly)
|
||||
if (isActive && activityText) {
|
||||
{/* Completed tools */}
|
||||
{hasCompleted && (
|
||||
<div className="min-w-0">
|
||||
{dedupedCompleted.map(({ tool, parsedTool }) => {
|
||||
const info = getToolDisplayInfo(parsedTool, getIconByToolName)
|
||||
if (!info) return null
|
||||
const isExpandable = EXPANDABLE_TOOLS.has(parsedTool.tool)
|
||||
const isItemExpanded = expandedItems[tool.ts] ?? false
|
||||
return (
|
||||
<div className="min-w-0" key={tool.ts}>
|
||||
{/* ACTIVE "READING..." ITEM STYLING - Modify vertical spacing here via py-0 and -my-0.5 */}
|
||||
<Button
|
||||
className="flex items-center gap-[3px] text-[13px] text-description py-[1px] min-w-0 max-w-full px-0 leading-tight -my-0.5"
|
||||
disabled
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[12px]" />
|
||||
<span className="flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left text-[13px]">
|
||||
<TypewriterText speed={15} text={activityText} />
|
||||
</span>{" "}
|
||||
</Button>
|
||||
</div>
|
||||
<FileToolRow
|
||||
displayText={info.displayText}
|
||||
expandedContent={parsedTool.content || null}
|
||||
filePath={info.path}
|
||||
icon={info.icon}
|
||||
isExpandable={isExpandable}
|
||||
isExpanded={isItemExpanded}
|
||||
key={tool.ts}
|
||||
onToggle={() => handleItemToggle(tool.ts)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
// Completed items render normally (clickable)
|
||||
return (
|
||||
<div className="min-w-0" key={tool.ts}>
|
||||
<Button
|
||||
className="flex items-center gap-[3px] cursor-pointer text-[13px] text-description py-[1px] hover:text-link min-w-0 max-w-full px-0 leading-tight -my-0.5"
|
||||
onClick={() => (isExpandable ? handleItemToggle(tool.ts) : handleOpenFile(info.path))}
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[12px]" />
|
||||
<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>
|
||||
{/* Expanded content for folders/search/definitions - file lists only */}
|
||||
{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>
|
||||
{/* Active tools (still streaming - typewriter animation) */}
|
||||
{hasActive && (
|
||||
<div className="min-w-0">
|
||||
{activeTools.map(({ tool, parsedTool, activityText }) => {
|
||||
const info = getToolDisplayInfo(parsedTool, getIconByToolName)
|
||||
if (!info) return null
|
||||
return (
|
||||
<FileToolRow
|
||||
activityText={activityText ?? undefined}
|
||||
filePath={info.path}
|
||||
icon={info.icon}
|
||||
isActive={true}
|
||||
key={tool.ts}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending approval files - only highlight the one currently awaiting approval */}
|
||||
{hasPendingApproval && (
|
||||
<div className="min-w-0">
|
||||
{pendingApprovalTools.map(({ tool, parsedTool }) => {
|
||||
const info = getToolDisplayInfo(parsedTool, getIconByToolName)
|
||||
if (!info) return null
|
||||
const isExpandable = EXPANDABLE_TOOLS.has(parsedTool.tool)
|
||||
const isItemExpanded = expandedItems[tool.ts] ?? false
|
||||
// Only highlight blue if this is the current pending ask
|
||||
// (the last ask tool in this group — the one currently awaiting approval)
|
||||
const isCurrentPendingAsk = tool.ts === currentPendingTs
|
||||
return (
|
||||
<FileToolRow
|
||||
displayText={info.displayText}
|
||||
expandedContent={parsedTool.content || null}
|
||||
filePath={info.path}
|
||||
icon={info.icon}
|
||||
isExpandable={isExpandable}
|
||||
isExpanded={isItemExpanded}
|
||||
isHighlighted={isCurrentPendingAsk}
|
||||
key={tool.ts}
|
||||
onToggle={() => handleItemToggle(tool.ts)}
|
||||
outsideWorkspace={parsedTool.operationIsLocatedInWorkspace === false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Build tool items WITHOUT reasoning.
|
||||
* Reasoning should not be displayed in file lists - only file/folder content.
|
||||
*/
|
||||
function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReasoning[] {
|
||||
const result: ToolWithReasoning[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip reasoning messages - they should not be in file lists
|
||||
if (msg.say === "reasoning") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLowStakesTool(msg)) {
|
||||
const parsedTool = parseToolSafe(msg.text)
|
||||
result.push({
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: undefined, // Never show reasoning in file lists
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse tool JSON, returning empty tool on failure.
|
||||
*/
|
||||
@@ -268,66 +308,12 @@ function parseToolSafe(text: string | undefined): ClineSayTool {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display info for a tool.
|
||||
* Get summary label for completed tools - shows what's been added to context.
|
||||
*/
|
||||
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 {
|
||||
function getToolGroupSummary(completedTools: ToolWithReasoning[]): 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)
|
||||
for (const { parsedTool: tool } of completedTools) {
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
counts.read++
|
||||
|
||||
@@ -721,7 +721,6 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
|
||||
let pendingReasoning: ClineMessage[] = []
|
||||
let pendingApiReq: ClineMessage[] = []
|
||||
let hasTools = false
|
||||
const pendingTools: ClineMessage[] = []
|
||||
|
||||
const flushPending = () => {
|
||||
pendingApiReq.forEach((m) => result.push(m))
|
||||
@@ -768,15 +767,12 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
|
||||
const isLast = i === groupedMessages.length - 1
|
||||
|
||||
// Low-stakes tool - absorb pending and add to group
|
||||
// Pending approval tools (ask type) stay in the group so ToolGroupRenderer
|
||||
// can show them in an accumulative list with a "wants to read" section.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -825,10 +821,6 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
|
||||
commitToolGroup()
|
||||
flushPending()
|
||||
|
||||
if (pendingTools.length > 0) {
|
||||
result.push(...pendingTools)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user