mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
fix: refine subagent streaming rows in cli and webview
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus, SubagentStatusItem } from "@shared/ExtensionMessage"
|
||||
import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
@@ -55,11 +55,16 @@ function formatCompactCost(cost: number | undefined): string {
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatSubagentStats(entry: SubagentStatusItem): string {
|
||||
const toolUses = entry.toolCalls === 1 ? "tool use" : "tool uses"
|
||||
const tokensUsed = formatCompactTokens(entry.contextTokens)
|
||||
const totalCost = formatCompactCost(entry.totalCost)
|
||||
return `${entry.toolCalls} ${toolUses} · ${tokensUsed} tokens · ${totalCost}`
|
||||
function formatSubagentStatsValues(
|
||||
toolCalls: number | undefined,
|
||||
contextTokens: number | undefined,
|
||||
totalCost: number | undefined,
|
||||
) {
|
||||
const safeToolCalls = Number.isFinite(toolCalls) ? Math.max(0, toolCalls || 0) : 0
|
||||
const toolUses = safeToolCalls === 1 ? "tool use" : "tool uses"
|
||||
const tokensUsed = formatCompactTokens(contextTokens || 0)
|
||||
const formattedCost = formatCompactCost(totalCost || 0)
|
||||
return `${safeToolCalls} ${toolUses} · ${tokensUsed} tokens · ${formattedCost}`
|
||||
}
|
||||
|
||||
function wrapPrompt(text: string, width: number): string[] {
|
||||
@@ -177,7 +182,7 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text color={toolColor}>Cline wants to run subagents</Text>
|
||||
<Text color={toolColor}>Cline wants to run subagents:</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
@@ -187,22 +192,30 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>{singular ? "Cline wants to run a subagent" : "Cline wants to run subagents"}</Text>
|
||||
<Text color={toolColor}>{singular ? "Cline wants to run a subagent:" : "Cline wants to run subagents:"}</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{prompts.map((prompt, index) => {
|
||||
const isLastPrompt = index === prompts.length - 1
|
||||
const branch = isLastPrompt ? "└─" : "├─"
|
||||
const continuationPrefix = isLastPrompt ? " " : "│ "
|
||||
const shouldShowPromptStats = partial !== true || !isLastPrompt
|
||||
return (
|
||||
<TreePromptRow
|
||||
color={toolColor}
|
||||
continuationPrefix={continuationPrefix}
|
||||
key={`${prompt}-${index}`}
|
||||
prefix={<Text color={toolColor}>{`${branch} `}</Text>}
|
||||
prompt={prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<Box flexDirection="column" key={`${prompt}-${index}`}>
|
||||
<TreePromptRow
|
||||
color={toolColor}
|
||||
continuationPrefix={continuationPrefix}
|
||||
prefix={<Text color={toolColor}>{`${branch} `}</Text>}
|
||||
prompt={prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
{shouldShowPromptStats && (
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(undefined, undefined, undefined)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
@@ -235,7 +248,7 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>
|
||||
{items.length === 1 ? "Cline is running a subagent" : "Cline is running subagents"}
|
||||
{items.length === 1 ? "Cline is running a subagent:" : "Cline is running subagents:"}
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
@@ -244,6 +257,7 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
const branch = isLastEntry ? "└─" : "├─"
|
||||
const continuationPrefix = isLastEntry ? " " : "│ "
|
||||
const key = `${entry.index}-${index}`
|
||||
const shouldShowStats = true
|
||||
|
||||
if (entry.status === "completed") {
|
||||
return (
|
||||
@@ -260,7 +274,10 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<TreeStatsRow prefix={continuationPrefix} stats={formatSubagentStats(entry)} />
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(entry.toolCalls, entry.contextTokens, entry.totalCost)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -280,7 +297,10 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<TreeStatsRow prefix={continuationPrefix} stats={formatSubagentStats(entry)} />
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(entry.toolCalls, entry.contextTokens, entry.totalCost)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -305,7 +325,12 @@ export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode,
|
||||
prompt={entry.prompt}
|
||||
promptWidth={promptWidth}
|
||||
/>
|
||||
<TreeStatsRow prefix={continuationPrefix} stats={formatSubagentStats(entry)} />
|
||||
{shouldShowStats && (
|
||||
<TreeStatsRow
|
||||
prefix={continuationPrefix}
|
||||
stats={formatSubagentStatsValues(entry.toolCalls, entry.contextTokens, entry.totalCost)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -39,8 +39,26 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
|
||||
return "[subagents]"
|
||||
}
|
||||
|
||||
async handlePartialBlock(_block: ToolUse, _uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
return
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const prompts = PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim()))
|
||||
.map((prompt) => prompt?.trim())
|
||||
.filter((prompt): prompt is string => !!prompt)
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const partialMessage = JSON.stringify({ prompts } satisfies ClineAskUseSubagents)
|
||||
const autoApproveResult = uiHelpers.shouldAutoApproveTool(this.name)
|
||||
const [shouldAutoApprove] = Array.isArray(autoApproveResult) ? autoApproveResult : [autoApproveResult, false]
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "use_subagents")
|
||||
await uiHelpers.say("use_subagents", partialMessage, undefined, undefined, block.partial)
|
||||
} else {
|
||||
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "use_subagents")
|
||||
await uiHelpers.ask("use_subagents", partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { ClineSubagentUsageInfo } from "@shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
import { SubagentRunner } from "../../subagent/SubagentRunner"
|
||||
import type { TaskConfig } from "../../types/TaskConfig"
|
||||
import { createUIHelpers } from "../../types/UIHelpers"
|
||||
import { UseSubagentsToolHandler } from "../SubagentToolHandler"
|
||||
|
||||
function createConfig(options?: {
|
||||
@@ -39,7 +42,7 @@ function createConfig(options?: {
|
||||
runUserPromptSubmitHook: sinon.stub().resolves({}),
|
||||
}
|
||||
|
||||
const config: any = {
|
||||
const config = {
|
||||
taskId: "task-1",
|
||||
ulid: "ulid-1",
|
||||
cwd: "/tmp",
|
||||
@@ -89,7 +92,7 @@ function createConfig(options?: {
|
||||
coordinator: {
|
||||
getHandler: sinon.stub(),
|
||||
},
|
||||
}
|
||||
} as unknown as TaskConfig
|
||||
|
||||
return { config, callbacks, taskState }
|
||||
}
|
||||
@@ -105,7 +108,7 @@ describe("SubagentToolHandler", () => {
|
||||
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: "use_subagents" as any,
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {},
|
||||
partial: false,
|
||||
})
|
||||
@@ -115,6 +118,62 @@ describe("SubagentToolHandler", () => {
|
||||
sinon.assert.calledOnce(callbacks.sayAndCreateMissingParamError)
|
||||
})
|
||||
|
||||
it("streams partial use_subagents approval as ask when not auto-approved", async () => {
|
||||
const { config, callbacks } = createConfig({ autoApproveSafe: false, autoApproveAll: false })
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const uiHelpers = createUIHelpers(config)
|
||||
|
||||
await handler.handlePartialBlock(
|
||||
{
|
||||
type: "tool_use",
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "first prompt",
|
||||
prompt_2: "second prompt",
|
||||
},
|
||||
partial: true,
|
||||
},
|
||||
uiHelpers,
|
||||
)
|
||||
|
||||
sinon.assert.calledOnce(callbacks.removeLastPartialMessageIfExistsWithType)
|
||||
sinon.assert.calledWithExactly(callbacks.removeLastPartialMessageIfExistsWithType, "say", "use_subagents")
|
||||
sinon.assert.calledOnce(callbacks.ask)
|
||||
sinon.assert.calledWithMatch(callbacks.ask, "use_subagents", sinon.match.string, true)
|
||||
|
||||
const payload = JSON.parse(callbacks.ask.firstCall.args[1])
|
||||
assert.deepEqual(payload.prompts, ["first prompt", "second prompt"])
|
||||
sinon.assert.notCalled(callbacks.say)
|
||||
})
|
||||
|
||||
it("streams partial use_subagents approval as say when auto-approved", async () => {
|
||||
const { config, callbacks } = createConfig({ autoApproveSafe: true, autoApproveAll: false })
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const uiHelpers = createUIHelpers(config)
|
||||
|
||||
await handler.handlePartialBlock(
|
||||
{
|
||||
type: "tool_use",
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "first prompt",
|
||||
prompt_2: "second prompt",
|
||||
},
|
||||
partial: true,
|
||||
},
|
||||
uiHelpers,
|
||||
)
|
||||
|
||||
sinon.assert.calledOnce(callbacks.removeLastPartialMessageIfExistsWithType)
|
||||
sinon.assert.calledWithExactly(callbacks.removeLastPartialMessageIfExistsWithType, "ask", "use_subagents")
|
||||
sinon.assert.calledOnce(callbacks.say)
|
||||
sinon.assert.calledWithMatch(callbacks.say, "use_subagents", sinon.match.string, undefined, undefined, true)
|
||||
|
||||
const payload = JSON.parse(callbacks.say.firstCall.args[1])
|
||||
assert.deepEqual(payload.prompts, ["first prompt", "second prompt"])
|
||||
sinon.assert.notCalled(callbacks.ask)
|
||||
})
|
||||
|
||||
it("uses one approval for the full batch and stops on denial", async () => {
|
||||
const { config, callbacks, taskState } = createConfig({ taskAskResponse: "noButtonClicked" })
|
||||
const runStub = sinon.stub(SubagentRunner.prototype, "run")
|
||||
@@ -122,7 +181,7 @@ describe("SubagentToolHandler", () => {
|
||||
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: "use_subagents" as any,
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "one",
|
||||
prompt_2: "two",
|
||||
@@ -158,7 +217,7 @@ describe("SubagentToolHandler", () => {
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: "use_subagents" as any,
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "one",
|
||||
},
|
||||
@@ -166,7 +225,7 @@ describe("SubagentToolHandler", () => {
|
||||
})
|
||||
|
||||
sinon.assert.notCalled(callbacks.ask)
|
||||
const subagentStatusCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent")
|
||||
const subagentStatusCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent")
|
||||
assert.ok(subagentStatusCalls.length >= 1)
|
||||
})
|
||||
|
||||
@@ -175,7 +234,7 @@ describe("SubagentToolHandler", () => {
|
||||
let activeRuns = 0
|
||||
let maxActiveRuns = 0
|
||||
|
||||
sinon.stub(SubagentRunner.prototype, "run").callsFake(async (_prompt: string, onProgress: any) => {
|
||||
sinon.stub(SubagentRunner.prototype, "run").callsFake(async (_prompt: string, onProgress) => {
|
||||
activeRuns++
|
||||
maxActiveRuns = Math.max(maxActiveRuns, activeRuns)
|
||||
onProgress({
|
||||
@@ -214,7 +273,7 @@ describe("SubagentToolHandler", () => {
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: "use_subagents" as any,
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "one",
|
||||
prompt_2: "two",
|
||||
@@ -227,12 +286,12 @@ describe("SubagentToolHandler", () => {
|
||||
assert.ok((result as string).includes("Total: 3"))
|
||||
assert.ok(maxActiveRuns > 1)
|
||||
|
||||
const subagentStatusCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent")
|
||||
const subagentStatusCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent")
|
||||
assert.ok(subagentStatusCalls.length >= 2)
|
||||
const finalCall = subagentStatusCalls[subagentStatusCalls.length - 1]
|
||||
assert.equal(finalCall.args[4], false)
|
||||
|
||||
const usageCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent_usage")
|
||||
const usageCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent_usage")
|
||||
assert.equal(usageCalls.length, 1)
|
||||
const usagePayload = JSON.parse(usageCalls[0].args[1]) as ClineSubagentUsageInfo
|
||||
assert.equal(usagePayload.source, "subagents")
|
||||
@@ -284,7 +343,7 @@ describe("SubagentToolHandler", () => {
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: "use_subagents" as any,
|
||||
name: ClineDefaultTool.USE_SUBAGENTS,
|
||||
params: {
|
||||
prompt_1: "succeed",
|
||||
prompt_2: "fail",
|
||||
|
||||
@@ -146,13 +146,14 @@ export default function SubagentStatusRow({ message, isLast, lastModifiedMessage
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.items.map((entry) => {
|
||||
{data.items.map((entry, _index) => {
|
||||
const displayStatus: DisplayStatus =
|
||||
wasCancelled && (entry.status === "running" || entry.status === "pending") ? "cancelled" : entry.status
|
||||
const hasDetails = Boolean(
|
||||
(entry.result && entry.status === "completed") || (entry.error && entry.status === "failed"),
|
||||
)
|
||||
const isExpanded = expandedItems[entry.index] === true
|
||||
const shouldShowStats = entry.status !== "pending"
|
||||
return (
|
||||
<div
|
||||
className="rounded-xs border border-editor-group-border bg-vscode-editor-background px-2 py-1.5"
|
||||
@@ -165,14 +166,16 @@ export default function SubagentStatusRow({ message, isLast, lastModifiedMessage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
{formatCount(entry.toolCalls)} tools called | {formatCount(entry.contextTokens)} tokens used |{" "}
|
||||
{formatCost(entry.totalCost)}
|
||||
</div>
|
||||
{shouldShowStats && (
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
{formatCount(entry.toolCalls)} tools called | {formatCount(entry.contextTokens)} tokens used |{" "}
|
||||
{formatCost(entry.totalCost)}
|
||||
</div>
|
||||
)}
|
||||
{hasDetails && (
|
||||
<button
|
||||
aria-label={isExpanded ? "Collapse subagent output" : "Expand subagent output"}
|
||||
className="mt-1 text-[11px] opacity-80 flex items-center gap-1.5 bg-transparent border-0 p-0 cursor-pointer text-left text-foreground"
|
||||
className={`${shouldShowStats ? "mt-1" : "mt-2"} text-[11px] opacity-80 flex items-center gap-1.5 bg-transparent border-0 p-0 cursor-pointer text-left text-foreground`}
|
||||
onClick={() => toggleItem(entry.index)}
|
||||
type="button">
|
||||
{isExpanded ? (
|
||||
|
||||
Reference in New Issue
Block a user