Compare commits

...
9 changed files with 116 additions and 94 deletions
+3
View File
@@ -35,6 +35,9 @@ Otherwise, if you have not completed the task and do not need additional informa
tooManyMistakes: (feedback?: string) =>
`You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${feedback}\n</feedback>`,
autoApprovalMaxReached: (feedback?: string) =>
`Auto-approval limit reached. The user has provided the following feedback to help guide you:\n<feedback>\n${feedback}\n</feedback>`,
missingToolParameterError: (paramName: string) =>
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
+35 -2
View File
@@ -1456,7 +1456,10 @@ export class Task {
try {
const { response, text, images, files } = await this.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// proceed while running
// proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
} else {
userFeedback = { text, images, files }
}
@@ -1947,12 +1950,42 @@ export class Task {
message: `Cline has auto-approved ${this.autoApprovalSettings.maxRequests.toString()} API requests.`,
})
}
await this.ask(
const { response, text, images, files } = await this.ask(
"auto_approval_max_req_reached",
`Cline has auto-approved ${this.autoApprovalSettings.maxRequests.toString()} API requests. Would you like to reset the count and proceed with the task?`,
)
// if we get past the promise it means the user approved and did not start a new task
this.taskState.consecutiveAutoApprovedRequestsCount = 0
// Process user feedback if provided
if (response === "messageResponse") {
// Display the user's message in the chat UI
await this.say("user_feedback", text, images, files)
// This userContent is for the *next* API call.
const feedbackUserContent: UserContent = []
feedbackUserContent.push({
type: "text",
text: formatResponse.autoApprovalMaxReached(text),
})
if (images && images.length > 0) {
feedbackUserContent.push(...formatResponse.imageBlocks(images))
}
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
if (fileContentString) {
feedbackUserContent.push({
type: "text",
text: fileContentString,
})
}
userContent = feedbackUserContent
}
}
// get previous api req's index to check token usage and determine if we need to truncate conversation history
+8 -2
View File
@@ -1551,7 +1551,10 @@ export const ChatRowContent = memo(
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "followup"}
isActive={
(isLast && lastModifiedMessage?.ask === "followup") ||
(!selected && options && options.length > 0)
}
inputValue={inputValue}
/>
{quoteButtonState.visible && (
@@ -1640,7 +1643,10 @@ export const ChatRowContent = memo(
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_respond"}
isActive={
(isLast && lastModifiedMessage?.ask === "plan_mode_respond") ||
(!selected && options && options.length > 0)
}
inputValue={inputValue}
/>
{quoteButtonState.visible && (
@@ -66,7 +66,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts} // trick to make sure virtuoso re-renders when task changes
key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
className="scrollable"
style={{
flexGrow: 1,
@@ -75,11 +75,12 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
components={{
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
}}
// increasing top by 3_000 to prevent jumping around when user collapses a row
increaseViewportBy={{
top: 3_000,
bottom: Number.MAX_SAFE_INTEGER,
}}
data={groupedMessages}
}} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
data={groupedMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
itemContent={itemContent}
atBottomStateChange={(isAtBottom) => {
setIsAtBottom(isAtBottom)
@@ -88,7 +89,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
}
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
}}
atBottomThreshold={10}
atBottomThreshold={10} // anything lower causes issues with followOutput
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
@@ -39,55 +39,3 @@ export const useIsStreaming = (
return false
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
}
/**
* Component that shows a visual streaming indicator
* Can be used to show loading states, typing indicators, etc.
*/
export const StreamingVisualIndicator: React.FC<{ isStreaming: boolean }> = ({ isStreaming }) => {
if (!isStreaming) return null
return (
<div
style={{
display: "flex",
alignItems: "center",
padding: "8px 16px",
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
<div
style={{
display: "flex",
gap: "4px",
marginRight: "8px",
}}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: "var(--vscode-progressBar-background)",
animation: `pulse 1.4s infinite ease-in-out ${i * 0.16}s`,
}}
/>
))}
</div>
<span>Cline is thinking...</span>
<style>{`
@keyframes pulse {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
`}</style>
</div>
)
}
@@ -3,4 +3,4 @@
*/
export { MessageRenderer, createMessageRenderer } from "./MessageRenderer"
export { useIsStreaming, StreamingVisualIndicator } from "./StreamingIndicator"
export { useIsStreaming } from "./StreamingIndicator"
@@ -57,6 +57,8 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
case "auto_approval_max_req_reached":
case "api_req_failed":
case "new_task":
case "condense":
case "report_bug":
@@ -111,34 +113,69 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
switch (clineAsk) {
case "api_req_failed":
case "command":
case "command_output":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
case "resume_task":
// For approval buttons, if there's input content, send it as a proper user message
// If there's no input content, just approve the action
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
// Send as a regular message so it appears in the conversation
await handleSendMessage(trimmedInput || "", images || [], files || [])
} else {
// No input content, just approve the action
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
// Clear input state after sending (only when no content was sent as a message)
setInputValue("")
setActiveQuote(null)
setSelectedImages([])
setSelectedFiles([])
}
break
case "mistake_limit_reached":
case "auto_approval_max_req_reached":
case "command_output":
// For proceed buttons, if there's input content, send it as a proper user message
// If there's no input content, just proceed with the action
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: trimmedInput,
images: images,
files: files,
}),
)
// Send as a regular message so it appears in the conversation
await handleSendMessage(trimmedInput || "", images || [], files || [])
} else {
// No input content, just proceed with the action
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
// Clear input state after sending (only when no content was sent as a message)
setInputValue("")
setActiveQuote(null)
setSelectedImages([])
setSelectedFiles([])
}
break
case "resume_task":
// For resume_task, if there's input content, send it as a proper user message
// If there's no input content, just resume the task
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
// Send as a regular message so it appears in the conversation
await handleSendMessage(trimmedInput || "", images || [], files || [])
} else {
// No input content, just resume the task
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
// Clear input state after sending (only when no content was sent as a message)
setInputValue("")
setActiveQuote(null)
setSelectedImages([])
setSelectedFiles([])
}
// Clear input state after sending
setInputValue("")
setActiveQuote(null)
setSelectedImages([])
setSelectedFiles([])
break
case "completion_result":
case "resume_completed_task":
@@ -185,6 +222,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
setSendingDisabled,
setEnableButtons,
chatState,
handleSendMessage,
],
)
@@ -32,8 +32,6 @@ export function useScrollBehavior(
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [pendingScrollToMessage, setPendingScrollToMessage] = useState<number | null>(null)
// Smooth scroll to bottom with debounce
const scrollToBottomSmooth = useMemo(
() =>
debounce(
@@ -49,7 +47,7 @@ export function useScrollBehavior(
[],
)
// Instant scroll to bottom
// Smooth scroll to bottom with debounce
const scrollToBottomAuto = useCallback(() => {
virtuosoRef.current?.scrollTo({
top: Number.MAX_SAFE_INTEGER,
@@ -57,7 +55,6 @@ export function useScrollBehavior(
})
}, [])
// Scroll to specific message
const scrollToMessage = useCallback(
(messageIndex: number) => {
setPendingScrollToMessage(messageIndex)
@@ -113,7 +110,7 @@ export function useScrollBehavior(
[messages, visibleMessages, groupedMessages],
)
// Toggle row expansion with scroll handling
// scroll when user toggles certain rows
const toggleRowExpansion = useCallback(
(ts: number) => {
const isCollapsing = expandedRows[ts] ?? false
@@ -165,10 +162,9 @@ export function useScrollBehavior(
}
}
},
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom, setExpandedRows],
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom],
)
// Handle row height changes
const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (!disableAutoScrollRef.current) {
@@ -184,23 +180,21 @@ export function useScrollBehavior(
[scrollToBottomSmooth, scrollToBottomAuto],
)
// Auto-scroll when new messages arrive
useEffect(() => {
if (!disableAutoScrollRef.current) {
setTimeout(() => {
scrollToBottomSmooth()
}, 50)
// return () => clearTimeout(timer) // dont cleanup since if visibleMessages.length changes it cancels.
}
}, [groupedMessages.length, scrollToBottomSmooth])
// Handle pending scroll to message
useEffect(() => {
if (pendingScrollToMessage !== null) {
scrollToMessage(pendingScrollToMessage)
}
}, [pendingScrollToMessage, groupedMessages, scrollToMessage])
// Handle wheel events to detect manual scrolling
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
@@ -210,8 +204,7 @@ export function useScrollBehavior(
}
}
}, [])
useEvent("wheel", handleWheel, window, { passive: true })
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
return {
virtuosoRef,
@@ -20,23 +20,23 @@ export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[]
return messages.filter((message) => {
switch (message.ask) {
case "completion_result":
// don't show a chat row for a completion_result ask without text
// don't show a chat row for a completion_result ask without text. This specific type of message only occurs if cline wants to execute a command as part of its completion result, in which case we interject the completion_result tool with the execute_command tool.
if (message.text === "") {
return false
}
break
case "api_req_failed":
case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
case "resume_task":
case "resume_completed_task":
return false
}
switch (message.say) {
case "api_req_finished":
case "api_req_retried":
case "deleted_api_reqs":
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
case "deleted_api_reqs": // aggregated api_req metrics from deleted messages
return false
case "text":
// Sometimes cline returns an empty text message, we don't want to render these
// 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) {
return false
}