mirror of
https://github.com/cline/cline.git
synced 2026-09-18 09:24:17 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e832ec7c |
@@ -123,6 +123,7 @@ message ClineSayTool {
|
||||
string regex = 5;
|
||||
string file_pattern = 6;
|
||||
bool operation_is_located_in_workspace = 7;
|
||||
int32 duration = 8;
|
||||
}
|
||||
|
||||
// Message for ClineSayBrowserAction
|
||||
@@ -221,6 +222,7 @@ message ClineMessage {
|
||||
ClineAskNewTask ask_new_task = 21;
|
||||
ClineApiReqInfo api_req_info = 22;
|
||||
ClineModelInfo model_info = 23;
|
||||
int32 duration = 24;
|
||||
}
|
||||
|
||||
message ShowWebviewEvent {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface TextStreamContent {
|
||||
type: "text"
|
||||
content: string
|
||||
partial: boolean
|
||||
duration?: number // duration in milliseconds
|
||||
}
|
||||
|
||||
export const toolParamNames = [
|
||||
@@ -71,6 +72,7 @@ export interface ToolUse {
|
||||
* Thought signature associated with this tool use, used by Gemini
|
||||
*/
|
||||
signature?: string
|
||||
duration?: number // duration in milliseconds
|
||||
}
|
||||
|
||||
export interface ReasoningStreamContent {
|
||||
@@ -101,4 +103,5 @@ export interface ReasoningStreamContent {
|
||||
* Indicates whether this is a partial reasoning block
|
||||
*/
|
||||
partial: boolean
|
||||
duration?: number // duration in milliseconds
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ClineReasoningDetailParam,
|
||||
} from "@/shared/messages/content"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
|
||||
export interface PendingToolUse {
|
||||
id: string
|
||||
@@ -18,6 +19,7 @@ export interface PendingToolUse {
|
||||
signature?: string
|
||||
jsonParser?: JSONParser
|
||||
call_id?: string
|
||||
start_time_ms: number
|
||||
}
|
||||
|
||||
interface ToolUseDeltaBlock {
|
||||
@@ -42,6 +44,7 @@ export interface PendingReasoning {
|
||||
signature: string
|
||||
redactedThinking: ClineAssistantRedactedThinkingBlock[]
|
||||
summary: unknown[] | ClineReasoningDetailParam[]
|
||||
start_time_ms: number
|
||||
}
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
@@ -55,8 +58,13 @@ const ESCAPE_MAP: Record<string, string> = {
|
||||
const ESCAPE_PATTERN = /\\[ntr"\\]/g
|
||||
|
||||
export class StreamResponseHandler {
|
||||
private toolUseHandler = new ToolUseHandler()
|
||||
private reasoningHandler = new ReasoningHandler()
|
||||
constructor(private autoApprove: AutoApprove) {
|
||||
this.toolUseHandler = new ToolUseHandler(this.autoApprove)
|
||||
this.reasonsHandler = new ReasoningHandler()
|
||||
}
|
||||
|
||||
private toolUseHandler: ToolUseHandler
|
||||
private reasonsHandler: ReasoningHandler
|
||||
|
||||
private _requestId: string | undefined
|
||||
|
||||
@@ -73,14 +81,14 @@ export class StreamResponseHandler {
|
||||
public getHandlers() {
|
||||
return {
|
||||
toolUseHandler: this.toolUseHandler,
|
||||
reasonsHandler: this.reasoningHandler,
|
||||
reasonsHandler: this.reasonsHandler,
|
||||
}
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this._requestId = undefined
|
||||
this.toolUseHandler = new ToolUseHandler()
|
||||
this.reasoningHandler = new ReasoningHandler()
|
||||
this.toolUseHandler = new ToolUseHandler(this.autoApprove)
|
||||
this.reasonsHandler = new ReasoningHandler()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +96,7 @@ export class StreamResponseHandler {
|
||||
* Handles streaming native tool use blocks and converts them to ClineAssistantToolUseBlock format
|
||||
*/
|
||||
class ToolUseHandler {
|
||||
constructor(private autoApprove: AutoApprove) {}
|
||||
private pendingToolUses = new Map<string, PendingToolUse>()
|
||||
|
||||
processToolUseDelta(delta: ToolUseDeltaBlock, call_id?: string): void {
|
||||
@@ -142,6 +151,8 @@ class ToolUseHandler {
|
||||
input,
|
||||
signature: pending.signature,
|
||||
call_id: pending.call_id,
|
||||
approved: this.autoApprove.shouldAutoApproveTool(pending.name as ClineDefaultTool) === true,
|
||||
duration_ms: Date.now() - pending.start_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +209,7 @@ class ToolUseHandler {
|
||||
isNativeToolCall: true,
|
||||
signature: pending.signature,
|
||||
call_id: pending.call_id,
|
||||
duration: Date.now() - pending.start_time_ms,
|
||||
})
|
||||
} else {
|
||||
const params: Record<string, string> = {}
|
||||
@@ -214,6 +226,7 @@ class ToolUseHandler {
|
||||
signature: pending.signature,
|
||||
isNativeToolCall: true,
|
||||
call_id: pending.call_id,
|
||||
duration: Date.now() - pending.start_time_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -235,6 +248,7 @@ class ToolUseHandler {
|
||||
jsonParser,
|
||||
call_id,
|
||||
signature: undefined,
|
||||
start_time_ms: Date.now(),
|
||||
}
|
||||
|
||||
jsonParser.onValue = (info: any) => {
|
||||
@@ -276,6 +290,7 @@ class ReasoningHandler {
|
||||
signature: "",
|
||||
redactedThinking: [],
|
||||
summary: [],
|
||||
start_time_ms: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +317,7 @@ class ReasoningHandler {
|
||||
type: "redacted_thinking",
|
||||
data: delta.redacted_data,
|
||||
call_id: delta.id || this.pendingReasoning.id,
|
||||
duration_ms: Date.now() - this.pendingReasoning.start_time_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -332,6 +348,7 @@ class ReasoningHandler {
|
||||
signature: this.pendingReasoning.signature,
|
||||
summary: this.pendingReasoning.summary,
|
||||
call_id: this.pendingReasoning.id,
|
||||
duration_ms: Date.now() - this.pendingReasoning.start_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+193
-175
@@ -105,6 +105,7 @@ import { MessageStateHandler } from "./message-state"
|
||||
import { StreamResponseHandler } from "./StreamResponseHandler"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
|
||||
import { buildUserFeedbackContent } from "./utils/buildUserFeedbackContent"
|
||||
|
||||
@@ -134,6 +135,18 @@ type TaskParams = {
|
||||
taskLockAcquired: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from handlePartialMessage indicating what happened
|
||||
*/
|
||||
interface HandlePartialMessageResult {
|
||||
/** The timestamp of the message */
|
||||
ts: number
|
||||
/** Whether an existing partial message was updated (vs creating a new message) */
|
||||
wasUpdated: boolean
|
||||
/** Whether this was a partial=true case that should throw to interrupt ask flow */
|
||||
shouldThrowForAsk: boolean
|
||||
}
|
||||
|
||||
export class Task {
|
||||
// Core task variables
|
||||
readonly taskId: string
|
||||
@@ -303,7 +316,7 @@ export class Task {
|
||||
this.urlContentFetcher = new UrlContentFetcher(controller.context)
|
||||
this.browserSession = new BrowserSession(stateManager)
|
||||
this.contextManager = new ContextManager()
|
||||
this.streamHandler = new StreamResponseHandler()
|
||||
this.streamHandler = new StreamResponseHandler(new AutoApprove(stateManager))
|
||||
this.cwd = cwd
|
||||
this.stateManager = stateManager
|
||||
this.workspaceManager = workspaceManager
|
||||
@@ -569,6 +582,147 @@ export class Task {
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
/**
|
||||
* Handle partial message logic for both ask() and say() methods.
|
||||
* Extracts the common pattern of updating/creating partial messages.
|
||||
*
|
||||
* @param messageCategory "ask" or "say"
|
||||
* @param messageType The specific ask or say type
|
||||
* @param text Message text content
|
||||
* @param partial Partial state: true (streaming), false (complete), undefined (single message)
|
||||
* @param options Additional options for the message
|
||||
* @returns Result indicating what action was taken
|
||||
*/
|
||||
private async handlePartialMessage(
|
||||
messageCategory: "ask" | "say",
|
||||
messageType: ClineAsk | ClineSay,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
options?: {
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
duration?: number
|
||||
modelInfo?: ClineMessageModelInfo
|
||||
},
|
||||
): Promise<HandlePartialMessageResult> {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
const lastMessageIndex = clineMessages.length - 1
|
||||
|
||||
// Check if we're updating an existing partial message of the same type
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage &&
|
||||
lastMessage.partial &&
|
||||
lastMessage.type === messageCategory &&
|
||||
(messageCategory === "ask" ? lastMessage.ask === messageType : lastMessage.say === messageType)
|
||||
|
||||
if (partial === true) {
|
||||
// partial=true: streaming in progress
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// Update existing partial message
|
||||
if (messageCategory === "say") {
|
||||
// For say, we mutate the message directly (existing behavior)
|
||||
lastMessage.text = text
|
||||
lastMessage.images = options?.images
|
||||
lastMessage.files = options?.files
|
||||
lastMessage.partial = partial
|
||||
lastMessage.duration = options?.duration
|
||||
} else {
|
||||
// For ask, we use updateClineMessage
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
}
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
return { ts: lastMessage.ts, wasUpdated: true, shouldThrowForAsk: messageCategory === "ask" }
|
||||
} else {
|
||||
// Create new partial message
|
||||
const ts = Date.now()
|
||||
this.taskState.lastMessageTs = ts
|
||||
const newMessage: ClineMessage = {
|
||||
ts,
|
||||
type: messageCategory,
|
||||
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
|
||||
text,
|
||||
partial,
|
||||
...(options?.images && { images: options.images }),
|
||||
...(options?.files && { files: options.files }),
|
||||
...(options?.modelInfo && { modelInfo: options.modelInfo }),
|
||||
...(options?.duration !== undefined && { duration: options.duration }),
|
||||
// Carry over duration from previous message for ask (existing behavior)
|
||||
...(messageCategory === "ask" && lastMessage?.duration && { duration: lastMessage.duration }),
|
||||
}
|
||||
await this.messageStateHandler.addToClineMessages(newMessage)
|
||||
await this.postStateToWebview()
|
||||
return { ts, wasUpdated: false, shouldThrowForAsk: messageCategory === "ask" }
|
||||
}
|
||||
} else if (partial === false) {
|
||||
// partial=false: completing a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// Complete the existing partial message
|
||||
const ts = lastMessage.ts
|
||||
this.taskState.lastMessageTs = ts
|
||||
|
||||
if (messageCategory === "say") {
|
||||
// For say, mutate directly and save (existing behavior)
|
||||
lastMessage.text = text
|
||||
lastMessage.images = options?.images
|
||||
lastMessage.files = options?.files
|
||||
lastMessage.partial = false
|
||||
lastMessage.duration = lastMessage.duration ?? options?.duration
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
} else {
|
||||
// For ask, use updateClineMessage
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial: false,
|
||||
})
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
}
|
||||
return { ts, wasUpdated: true, shouldThrowForAsk: false }
|
||||
} else {
|
||||
// New message with partial=false (treated as complete message)
|
||||
const ts = Date.now()
|
||||
this.taskState.lastMessageTs = ts
|
||||
const newMessage: ClineMessage = {
|
||||
ts,
|
||||
type: messageCategory,
|
||||
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
|
||||
text,
|
||||
...(options?.images && { images: options.images }),
|
||||
...(options?.files && { files: options.files }),
|
||||
...(options?.modelInfo && { modelInfo: options.modelInfo }),
|
||||
...(options?.duration !== undefined && { duration: options.duration }),
|
||||
}
|
||||
await this.messageStateHandler.addToClineMessages(newMessage)
|
||||
await this.postStateToWebview()
|
||||
return { ts, wasUpdated: false, shouldThrowForAsk: false }
|
||||
}
|
||||
} else {
|
||||
// partial=undefined: new non-partial message (individual complete message)
|
||||
const ts = Date.now()
|
||||
this.taskState.lastMessageTs = ts
|
||||
const newMessage: ClineMessage = {
|
||||
ts,
|
||||
type: messageCategory,
|
||||
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
|
||||
text,
|
||||
...(options?.images && { images: options.images }),
|
||||
...(options?.files && { files: options.files }),
|
||||
...(options?.modelInfo && { modelInfo: options.modelInfo }),
|
||||
...(options?.duration !== undefined && { duration: options.duration }),
|
||||
}
|
||||
await this.messageStateHandler.addToClineMessages(newMessage)
|
||||
await this.postStateToWebview()
|
||||
return { ts, wasUpdated: false, shouldThrowForAsk: false }
|
||||
}
|
||||
}
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
async ask(
|
||||
type: ClineAsk,
|
||||
@@ -580,116 +734,37 @@ export class Task {
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
askTs?: number
|
||||
duration?: number
|
||||
}> {
|
||||
// Allow resume asks even when aborted to enable resume button after cancellation
|
||||
if (this.taskState.abort && type !== "resume_task" && type !== "resume_completed_task") {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
let askTs: number
|
||||
if (partial !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
const lastMessageIndex = clineMessages.length - 1
|
||||
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
throw new Error("Current ask promise was ignored 1")
|
||||
} else {
|
||||
// this is a new partial message, so add it with partial state
|
||||
// this.askResponse = undefined
|
||||
// this.askResponseText = undefined
|
||||
// this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
type: "ask",
|
||||
ask: type,
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
throw new Error("Current ask promise was ignored 2")
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
const result = await this.handlePartialMessage("ask", type, text, partial)
|
||||
|
||||
/*
|
||||
Bug for the history books:
|
||||
In the webview we use the ts as the chatrow key for the virtuoso list. Since we would update this ts right at the end of streaming, it would cause the view to flicker. The key prop has to be stable otherwise react has trouble reconciling items between renders, causing unmounting and remounting of components (flickering).
|
||||
The lesson here is if you see flickering when rendering lists, it's likely because the key prop is not stable.
|
||||
So in this case we must make sure that the message ts is never altered after first setting it.
|
||||
*/
|
||||
askTs = lastMessage.ts
|
||||
this.taskState.lastMessageTs = askTs
|
||||
// lastMessage.ts = askTs
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial: false,
|
||||
})
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
} else {
|
||||
// this is a new partial=false message, so add it like normal
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
askTs = Date.now()
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
type: "ask",
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// this is a new non-partial message, so add it like normal
|
||||
// const lastMessage = this.clineMessages.at(-1)
|
||||
// For ask, partial=true cases should throw to interrupt the ask flow
|
||||
if (result.shouldThrowForAsk) {
|
||||
throw new Error("Current ask promise was ignored")
|
||||
}
|
||||
|
||||
// Clear response state when not streaming (partial=true)
|
||||
if (!partial) {
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
askTs = Date.now()
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
type: "ask",
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
const askTs = result.ts
|
||||
|
||||
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
|
||||
interval: 100,
|
||||
})
|
||||
if (this.taskState.lastMessageTs !== askTs) {
|
||||
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
|
||||
}
|
||||
const result = {
|
||||
const askResult = {
|
||||
response: this.taskState.askResponse!,
|
||||
text: this.taskState.askResponseText,
|
||||
images: this.taskState.askResponseImages,
|
||||
@@ -699,7 +774,7 @@ export class Task {
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
return result
|
||||
return askResult
|
||||
}
|
||||
|
||||
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[], files?: string[]) {
|
||||
@@ -715,6 +790,7 @@ export class Task {
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
duration?: number,
|
||||
): Promise<number | undefined> {
|
||||
// Allow hook messages even when aborted to enable proper cleanup
|
||||
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
@@ -728,87 +804,15 @@ export class Task {
|
||||
mode: providerInfo.mode,
|
||||
}
|
||||
|
||||
if (partial !== undefined) {
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.files = files
|
||||
lastMessage.partial = partial
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
return undefined
|
||||
} else {
|
||||
// this is a new partial message, so add it with partial state
|
||||
const sayTs = Date.now()
|
||||
this.taskState.lastMessageTs = sayTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
partial,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.lastMessageTs = lastMessage.ts
|
||||
// lastMessage.ts = sayTs
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.files = files // Ensure files is updated
|
||||
lastMessage.partial = false
|
||||
const result = await this.handlePartialMessage("say", type, text, partial, {
|
||||
images,
|
||||
files,
|
||||
duration,
|
||||
modelInfo,
|
||||
})
|
||||
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
|
||||
return undefined
|
||||
} else {
|
||||
// this is a new partial=false message, so add it like normal
|
||||
const sayTs = Date.now()
|
||||
this.taskState.lastMessageTs = sayTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// this is a new non-partial message, so add it like normal
|
||||
const sayTs = Date.now()
|
||||
this.taskState.lastMessageTs = sayTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
// For say, return undefined when updating existing partial, otherwise return the timestamp
|
||||
return result.wasUpdated ? undefined : result.ts
|
||||
}
|
||||
|
||||
async sayAndCreateMissingParamError(toolName: ClineDefaultTool, paramName: string, relPath?: string) {
|
||||
@@ -2566,7 +2570,14 @@ export class Task {
|
||||
if (!this.taskState.abort) {
|
||||
const thinkingBlock = reasonsHandler.getCurrentReasoning()
|
||||
if (thinkingBlock?.thinking && chunk.reasoning) {
|
||||
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
|
||||
await this.say(
|
||||
"reasoning",
|
||||
thinkingBlock.thinking,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
thinkingBlock.duration_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2599,7 +2610,14 @@ export class Task {
|
||||
const currentReasoning = reasonsHandler.getCurrentReasoning()
|
||||
if (currentReasoning?.thinking && assistantMessage.length === 0) {
|
||||
// Complete the reasoning message (only once)
|
||||
await this.say("reasoning", currentReasoning.thinking, undefined, undefined, false)
|
||||
await this.say(
|
||||
"reasoning",
|
||||
currentReasoning.thinking,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
currentReasoning.duration_ms,
|
||||
)
|
||||
}
|
||||
if (chunk.signature) {
|
||||
assistantTextSignature = chunk.signature
|
||||
|
||||
@@ -158,6 +158,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
...sharedMessageProps,
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
duration: block.duration,
|
||||
// ? formatResponse.createPrettyPatch(
|
||||
// relPath,
|
||||
// this.diffViewProvider.originalContent,
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface ClineMessage {
|
||||
conversationHistoryIndex?: number
|
||||
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
|
||||
modelInfo?: ClineMessageModelInfo
|
||||
duration?: number // duration in milliseconds
|
||||
}
|
||||
|
||||
export type ClineAsk =
|
||||
@@ -208,6 +209,7 @@ export interface ClineSayTool {
|
||||
regex?: string
|
||||
filePattern?: string
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
duration?: number // duration in milliseconds
|
||||
}
|
||||
|
||||
export interface ClineSayHook {
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ClineReasoningDetailParam {
|
||||
interface ClineSharedMessageParam {
|
||||
// The id of the response that the block belongs to
|
||||
call_id?: string
|
||||
duration_ms?: number
|
||||
approved?: boolean
|
||||
}
|
||||
|
||||
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
|
||||
@@ -36,7 +38,9 @@ export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, Cline
|
||||
|
||||
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
|
||||
|
||||
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
|
||||
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant only content types
|
||||
@@ -127,18 +131,14 @@ export function convertClineStorageToAnthropicMessage(
|
||||
return { role, content: cleanedContent }
|
||||
}
|
||||
|
||||
const CLINE_UNIQUE_FIELDS = ["reasoning_details", "call_id", "summary", "signature", "duration_ms", "approved"]
|
||||
|
||||
/**
|
||||
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
|
||||
*/
|
||||
export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
|
||||
// Fast path: if no Cline-specific fields exist, return as-is
|
||||
const hasClineFields =
|
||||
"reasoning_details" in block ||
|
||||
"call_id" in block ||
|
||||
"summary" in block ||
|
||||
(block.type !== "thinking" && "signature" in block)
|
||||
|
||||
if (!hasClineFields) {
|
||||
if (!CLINE_UNIQUE_FIELDS.some((f) => f in block) || (block.type !== "thinking" && "signature" in block)) {
|
||||
return block as Anthropic.ContentBlock
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ export function convertClineMessageToProto(message: AppClineMessage): ProtoCline
|
||||
askNewTask: undefined,
|
||||
apiReqInfo: undefined,
|
||||
modelInfo: message.modelInfo ?? undefined,
|
||||
duration: message.duration ?? 0,
|
||||
}
|
||||
|
||||
return protoMessage
|
||||
@@ -276,5 +277,9 @@ export function convertProtoToClineMessage(protoMessage: ProtoClineMessage): App
|
||||
]
|
||||
}
|
||||
|
||||
if (protoMessage.duration !== 0) {
|
||||
message.duration = protoMessage.duration
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -786,6 +786,8 @@ export const ChatRowContent = memo(
|
||||
)
|
||||
}
|
||||
|
||||
console.log("Rendering ChatRowContent for message:", message.duration)
|
||||
|
||||
switch (message.type) {
|
||||
case "say":
|
||||
switch (message.say) {
|
||||
@@ -913,6 +915,8 @@ export const ChatRowContent = memo(
|
||||
return activities
|
||||
}, [clineMessages])
|
||||
|
||||
console.log("API Req State:", message)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{apiReqState === "pre" && (
|
||||
@@ -935,15 +939,14 @@ export const ChatRowContent = memo(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reasoningContent && (
|
||||
<ThinkingRow
|
||||
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
|
||||
isVisible={true}
|
||||
onToggle={handleToggle}
|
||||
reasoningContent={reasoningContent}
|
||||
showTitle={false}
|
||||
/>
|
||||
)}
|
||||
<ThinkingRow
|
||||
duration={message.duration}
|
||||
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
|
||||
isVisible={!!reasoningContent}
|
||||
onToggle={handleToggle}
|
||||
reasoningContent={reasoningContent}
|
||||
showTitle={false}
|
||||
/>
|
||||
|
||||
{apiReqState === "error" && (
|
||||
<ErrorRow
|
||||
|
||||
@@ -370,6 +370,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
<MessagesArea
|
||||
chatState={chatState}
|
||||
groupedMessages={groupedMessages}
|
||||
key={task.ts}
|
||||
messageHandlers={messageHandlers}
|
||||
modifiedMessages={modifiedMessages}
|
||||
scrollBehavior={scrollBehavior}
|
||||
|
||||
@@ -9,66 +9,73 @@ interface ThinkingRowProps {
|
||||
isVisible: boolean
|
||||
isExpanded: boolean
|
||||
onToggle?: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle }: ThinkingRowProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
export const ThinkingRow = memo(
|
||||
({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle, duration }: 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])
|
||||
// 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={cn(
|
||||
"ml-1 transition-opacity duration-200",
|
||||
!isVisible && "hidden opacity-0",
|
||||
isVisible && "starting:opacity-0",
|
||||
)}
|
||||
hidden={!isVisible}>
|
||||
{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>
|
||||
) : (
|
||||
<div>{duration}</div>
|
||||
)}
|
||||
|
||||
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
|
||||
{isExpanded && (
|
||||
<Button
|
||||
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]",
|
||||
"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",
|
||||
{
|
||||
"pl-2 border-l border-description/50": showTitle,
|
||||
"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,
|
||||
},
|
||||
)}
|
||||
ref={scrollRef}>
|
||||
<span>{reasoningContent}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
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"
|
||||
|
||||
@@ -84,7 +84,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex flex-col h-full relative">
|
||||
<div className="overflow-hidden flex flex-col h-full relative animate-fade-in">
|
||||
{/* Sticky User Message - positioned absolutely to avoid layout shifts */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user