fix: refine subagent streaming rows in cli and webview

This commit is contained in:
Saoud Rizwan
2026-02-09 17:31:35 -08:00
parent 7c993284dc
commit d0e41fe779
4 changed files with 144 additions and 39 deletions
+45 -20
View File
@@ -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 ? (