feat: track line changes for delegated subtasks

Compute per-task lines added/removed from tool diffStats, persist on history items, and aggregate recursively across descendants.\n\nRender +/− in todo rows, task header, and expanded task details with diff-like green/red styling. Update i18n labels across locales and adjust layout/alignment.\n\nRelates to PR #10765
This commit is contained in:
Toray Altas 2026-01-18 23:01:19 -05:00
parent 3445ca1ecd
commit 235cffad3b
30 changed files with 629 additions and 32 deletions

View file

@ -16,6 +16,8 @@ export const historyItemSchema = z.object({
cacheWrites: z.number().optional(),
cacheReads: z.number().optional(),
totalCost: z.number(),
linesAdded: z.number().optional(),
linesRemoved: z.number().optional(),
size: z.number().optional(),
workspace: z.string().optional(),
mode: z.string().optional(),

View file

@ -18,6 +18,8 @@ export const todoItemSchema = z.object({
subtaskId: z.string().optional(), // ID of the linked subtask (child task) for direct cost/token attribution
tokens: z.number().optional(), // Total tokens (in + out) for linked subtask
cost: z.number().optional(), // Total cost for linked subtask
added: z.number().optional(),
removed: z.number().optional(),
})
export type TodoItem = z.infer<typeof todoItemSchema>

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// Hoisted mocks to avoid initialization ordering issues
const hoisted = vi.hoisted(() => ({
getTaskDirectoryPathMock: vi.fn().mockResolvedValue("/mock/task/dir"),
getFolderSizeLooseMock: vi.fn().mockResolvedValue(0),
getApiMetricsMock: vi.fn().mockReturnValue({
totalTokensIn: 0,
totalTokensOut: 0,
totalCacheWrites: 0,
totalCacheReads: 0,
totalCost: 0,
contextTokens: 0,
}),
}))
vi.mock("get-folder-size", () => ({
default: {
loose: hoisted.getFolderSizeLooseMock,
},
}))
vi.mock("../../../utils/storage", () => ({
getTaskDirectoryPath: hoisted.getTaskDirectoryPathMock,
}))
vi.mock("../../../shared/getApiMetrics", () => ({
getApiMetrics: hoisted.getApiMetricsMock,
}))
// Import after mocks
import { taskMetadata } from "../taskMetadata"
describe("taskMetadata() line change parsing", () => {
beforeEach(() => {
hoisted.getTaskDirectoryPathMock.mockClear()
hoisted.getFolderSizeLooseMock.mockClear()
hoisted.getApiMetricsMock.mockClear()
})
it("computes linesAdded/linesRemoved from tool message diffStats", async () => {
const result = await taskMetadata({
taskId: "task-1",
taskNumber: 1,
globalStoragePath: "/mock/global",
workspace: "/mock/workspace",
messages: [
{ ts: 1, type: "say", say: "text", text: "Task" } as any,
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({ diffStats: { added: 5, removed: 2 } }),
} as any,
],
})
expect(result.historyItem.linesAdded).toBe(5)
expect(result.historyItem.linesRemoved).toBe(2)
})
it("aggregates linesAdded/linesRemoved from batch tool message batchDiffs[].diffStats", async () => {
const result = await taskMetadata({
taskId: "task-2",
taskNumber: 2,
globalStoragePath: "/mock/global",
workspace: "/mock/workspace",
messages: [
{ ts: 1, type: "say", say: "text", text: "Task" } as any,
{
ts: 2,
type: "ask",
ask: "tool",
text: JSON.stringify({
batchDiffs: [
{ path: "a.ts", diffStats: { added: 1, removed: 1 } },
{ path: "b.ts", diffStats: { added: 2, removed: 3 } },
],
}),
} as any,
],
})
expect(result.historyItem.linesAdded).toBe(3)
expect(result.historyItem.linesRemoved).toBe(4)
})
it("ignores partial tool messages", async () => {
const result = await taskMetadata({
taskId: "task-3",
taskNumber: 3,
globalStoragePath: "/mock/global",
workspace: "/mock/workspace",
messages: [
{ ts: 1, type: "say", say: "text", text: "Task" } as any,
{
ts: 2,
type: "ask",
ask: "tool",
partial: true,
text: JSON.stringify({ diffStats: { added: 10, removed: 10 } }),
} as any,
],
})
expect(result.historyItem.linesAdded).toBeUndefined()
expect(result.historyItem.linesRemoved).toBeUndefined()
})
it("ignores invalid JSON in tool message text gracefully", async () => {
const result = await taskMetadata({
taskId: "task-4",
taskNumber: 4,
globalStoragePath: "/mock/global",
workspace: "/mock/workspace",
messages: [
{ ts: 1, type: "say", say: "text", text: "Task" } as any,
{ ts: 2, type: "ask", ask: "tool", text: "{not-json" } as any,
],
})
expect(result.historyItem.linesAdded).toBeUndefined()
expect(result.historyItem.linesRemoved).toBeUndefined()
})
})

View file

@ -12,6 +12,64 @@ import { t } from "../../i18n"
const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
type DiffStats = { added: number; removed: number }
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value)
}
function isDiffStats(value: unknown): value is DiffStats {
if (!value || typeof value !== "object") return false
const v = value as { added?: unknown; removed?: unknown }
return isFiniteNumber(v.added) && isFiniteNumber(v.removed)
}
function getLineStatsFromToolApprovalMessages(messages: ClineMessage[]): {
linesAdded: number
linesRemoved: number
foundAnyStats: boolean
} {
let linesAdded = 0
let linesRemoved = 0
let foundAnyStats = false
for (const m of messages) {
// Only count complete tool approval asks (avoid double-counting partial/streaming updates)
if (!(m.type === "ask" && m.ask === "tool" && m.partial !== true)) continue
if (typeof m.text !== "string" || m.text.length === 0) continue
let payload: unknown
try {
payload = JSON.parse(m.text)
} catch {
continue
}
if (!payload || typeof payload !== "object") continue
const p = payload as { diffStats?: unknown; batchDiffs?: unknown }
if (isDiffStats(p.diffStats)) {
linesAdded += p.diffStats.added
linesRemoved += p.diffStats.removed
foundAnyStats = true
}
if (Array.isArray(p.batchDiffs)) {
for (const batchDiff of p.batchDiffs) {
if (!batchDiff || typeof batchDiff !== "object") continue
const bd = batchDiff as { diffStats?: unknown }
if (!isDiffStats(bd.diffStats)) continue
linesAdded += bd.diffStats.added
linesRemoved += bd.diffStats.removed
foundAnyStats = true
}
}
}
return { linesAdded, linesRemoved, foundAnyStats }
}
export type TaskMetadataOptions = {
taskId: string
rootTaskId?: string
@ -55,6 +113,8 @@ export async function taskMetadata({
let tokenUsage: ReturnType<typeof getApiMetrics>
let taskDirSize: number
let taskMessage: ClineMessage | undefined
let linesAdded: number | undefined
let linesRemoved: number | undefined
if (!hasMessages) {
// Handle no messages case
@ -93,6 +153,12 @@ export async function taskMetadata({
} else {
taskDirSize = cachedSize
}
const lineStats = getLineStatsFromToolApprovalMessages(messages)
if (lineStats.foundAnyStats) {
linesAdded = lineStats.linesAdded
linesRemoved = lineStats.linesRemoved
}
}
// Create historyItem once with pre-calculated values.
@ -115,6 +181,8 @@ export async function taskMetadata({
cacheWrites: tokenUsage.totalCacheWrites,
cacheReads: tokenUsage.totalCacheReads,
totalCost: tokenUsage.totalCost,
...(typeof linesAdded === "number" ? { linesAdded } : {}),
...(typeof linesRemoved === "number" ? { linesRemoved } : {}),
size: taskDirSize,
workspace,
mode,

View file

@ -3372,6 +3372,8 @@ export class ClineProvider
if (linkedTodo) {
linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0)
linkedTodo.cost = childHistoryItem?.totalCost || 0
linkedTodo.added = childHistoryItem?.linesAdded || 0
linkedTodo.removed = childHistoryItem?.linesRemoved || 0
parentClineMessages.push({
ts: Date.now(),

View file

@ -53,11 +53,15 @@ describe("aggregateTaskCostsRecursive", () => {
parent: {
id: "parent",
totalCost: 1.0,
linesAdded: 2,
linesRemoved: 1,
childIds: ["child-1"],
} as unknown as HistoryItem,
"child-1": {
id: "child-1",
totalCost: 0.5,
linesAdded: 3,
linesRemoved: 2,
childIds: [],
} as unknown as HistoryItem,
}
@ -69,6 +73,12 @@ describe("aggregateTaskCostsRecursive", () => {
expect(result.ownCost).toBe(1.0)
expect(result.childrenCost).toBe(0.5)
expect(result.totalCost).toBe(1.5)
expect(result.ownAdded).toBe(2)
expect(result.ownRemoved).toBe(1)
expect(result.childrenAdded).toBe(3)
expect(result.childrenRemoved).toBe(2)
expect(result.totalAdded).toBe(5)
expect(result.totalRemoved).toBe(3)
expect(result.childBreakdown).toHaveProperty("child-1")
const child1 = result.childBreakdown?.["child-1"]
expect(child1).toBeDefined()
@ -114,16 +124,22 @@ describe("aggregateTaskCostsRecursive", () => {
parent: {
id: "parent",
totalCost: 1.0,
linesAdded: 2,
linesRemoved: 2,
childIds: ["child"],
} as unknown as HistoryItem,
child: {
id: "child",
totalCost: 0.5,
linesAdded: 3,
linesRemoved: 1,
childIds: ["grandchild"],
} as unknown as HistoryItem,
grandchild: {
id: "grandchild",
totalCost: 0.25,
linesAdded: 1,
linesRemoved: 4,
childIds: [],
} as unknown as HistoryItem,
}
@ -136,12 +152,22 @@ describe("aggregateTaskCostsRecursive", () => {
expect(result.childrenCost).toBe(0.75) // child (0.5) + grandchild (0.25)
expect(result.totalCost).toBe(1.75)
expect(result.ownAdded).toBe(2)
expect(result.ownRemoved).toBe(2)
// children totals include all descendants
expect(result.childrenAdded).toBe(4) // child (3) + grandchild (1)
expect(result.childrenRemoved).toBe(5) // child (1) + grandchild (4)
expect(result.totalAdded).toBe(6)
expect(result.totalRemoved).toBe(7)
// Verify child breakdown
const child = result.childBreakdown?.["child"]
expect(child).toBeDefined()
expect(child!.ownCost).toBe(0.5)
expect(child!.childrenCost).toBe(0.25)
expect(child!.totalCost).toBe(0.75)
expect(child!.totalAdded).toBe(4)
expect(child!.totalRemoved).toBe(5)
// Verify grandchild breakdown
const grandchild = child!.childBreakdown?.["grandchild"]
@ -149,6 +175,8 @@ describe("aggregateTaskCostsRecursive", () => {
expect(grandchild!.ownCost).toBe(0.25)
expect(grandchild!.childrenCost).toBe(0)
expect(grandchild!.totalCost).toBe(0.25)
expect(grandchild!.totalAdded).toBe(1)
expect(grandchild!.totalRemoved).toBe(4)
})
it("should detect and prevent circular references", async () => {
@ -156,11 +184,15 @@ describe("aggregateTaskCostsRecursive", () => {
"task-a": {
id: "task-a",
totalCost: 1.0,
linesAdded: 2,
linesRemoved: 3,
childIds: ["task-b"],
} as unknown as HistoryItem,
"task-b": {
id: "task-b",
totalCost: 0.5,
linesAdded: 4,
linesRemoved: 1,
childIds: ["task-a"], // Circular reference back to task-a
} as unknown as HistoryItem,
}
@ -173,6 +205,8 @@ describe("aggregateTaskCostsRecursive", () => {
expect(result.ownCost).toBe(1.0)
expect(result.childrenCost).toBe(0.5) // Only task-b's own cost, circular ref returns 0
expect(result.totalCost).toBe(1.5)
expect(result.totalAdded).toBe(6) // task-a (2) + task-b (4)
expect(result.totalRemoved).toBe(4) // task-a (3) + task-b (1)
// Verify warning was logged
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Circular reference detected: task-a"))
@ -333,11 +367,23 @@ describe("buildSubtaskDetails", () => {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 10,
totalRemoved: 5,
},
"child-2": {
ownCost: 0.3,
childrenCost: 0.2,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 1,
totalRemoved: 2,
},
}
@ -369,6 +415,8 @@ describe("buildSubtaskDetails", () => {
expect(child1!.name).toBe("First subtask")
expect(child1!.tokens).toBe(150) // 100 + 50
expect(child1!.cost).toBe(0.5)
expect(child1!.added).toBe(10)
expect(child1!.removed).toBe(5)
expect(child1!.status).toBe("completed")
expect(child1!.hasNestedChildren).toBe(false)
@ -377,6 +425,8 @@ describe("buildSubtaskDetails", () => {
expect(child2!.name).toBe("Second subtask with nested children")
expect(child2!.tokens).toBe(300) // 200 + 100
expect(child2!.cost).toBe(0.5)
expect(child2!.added).toBe(1)
expect(child2!.removed).toBe(2)
expect(child2!.status).toBe("active")
expect(child2!.hasNestedChildren).toBe(true) // childrenCost > 0
})
@ -389,6 +439,12 @@ describe("buildSubtaskDetails", () => {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -418,6 +474,12 @@ describe("buildSubtaskDetails", () => {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -445,11 +507,23 @@ describe("buildSubtaskDetails", () => {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
"missing-child": {
ownCost: 0.3,
childrenCost: 0,
totalCost: 0.3,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -488,6 +562,12 @@ describe("buildSubtaskDetails", () => {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -514,6 +594,12 @@ describe("buildSubtaskDetails", () => {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}

View file

@ -8,6 +8,8 @@ export interface SubtaskDetail {
name: string // First 50 chars of task description
tokens: number // tokensIn + tokensOut
cost: number // Aggregated total cost
added: number // Aggregated total lines added
removed: number // Aggregated total lines removed
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean // Has its own subtasks
}
@ -16,6 +18,12 @@ export interface AggregatedCosts {
ownCost: number // This task's own API costs
childrenCost: number // Sum of all direct children costs (recursive)
totalCost: number // ownCost + childrenCost
ownAdded: number // This task's own lines added
ownRemoved: number // This task's own lines removed
childrenAdded: number // Sum of all descendant lines added
childrenRemoved: number // Sum of all descendant lines removed
totalAdded: number // ownAdded + childrenAdded
totalRemoved: number // ownRemoved + childrenRemoved
childBreakdown?: {
// Optional detailed breakdown
[childId: string]: AggregatedCosts
@ -39,7 +47,17 @@ export async function aggregateTaskCostsRecursive(
// Prevent infinite loops
if (visited.has(taskId)) {
console.warn(`[aggregateTaskCostsRecursive] Circular reference detected: ${taskId}`)
return { ownCost: 0, childrenCost: 0, totalCost: 0 }
return {
ownCost: 0,
childrenCost: 0,
totalCost: 0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
}
}
visited.add(taskId)
@ -47,11 +65,25 @@ export async function aggregateTaskCostsRecursive(
const history = await getTaskHistory(taskId)
if (!history) {
console.warn(`[aggregateTaskCostsRecursive] Task ${taskId} not found`)
return { ownCost: 0, childrenCost: 0, totalCost: 0 }
return {
ownCost: 0,
childrenCost: 0,
totalCost: 0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
}
}
const ownCost = history.totalCost || 0
const ownAdded = history.linesAdded || 0
const ownRemoved = history.linesRemoved || 0
let childrenCost = 0
let childrenAdded = 0
let childrenRemoved = 0
const childBreakdown: { [childId: string]: AggregatedCosts } = {}
// Recursively aggregate child costs
@ -63,6 +95,8 @@ export async function aggregateTaskCostsRecursive(
new Set(visited), // Create new Set to allow sibling traversal
)
childrenCost += childAggregated.totalCost
childrenAdded += childAggregated.totalAdded
childrenRemoved += childAggregated.totalRemoved
childBreakdown[childId] = childAggregated
}
}
@ -71,6 +105,12 @@ export async function aggregateTaskCostsRecursive(
ownCost,
childrenCost,
totalCost: ownCost + childrenCost,
ownAdded,
ownRemoved,
childrenAdded,
childrenRemoved,
totalAdded: ownAdded + childrenAdded,
totalRemoved: ownRemoved + childrenRemoved,
childBreakdown,
}
@ -104,6 +144,8 @@ export async function buildSubtaskDetails(
name: truncateTaskName(history.task, 50),
tokens: (history.tokensIn || 0) + (history.tokensOut || 0),
cost: costs.totalCost,
added: costs.totalAdded,
removed: costs.totalRemoved,
status: history.status || "completed",
hasNestedChildren: costs.childrenCost > 0,
})

View file

@ -85,7 +85,7 @@ const TaskHeader = ({
? (() => {
const lastRelevantIndex = findLastIndex(
clineMessages,
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
(m) => !((m as any)?.ask === "resume_task" || (m as any)?.ask === "resume_completed_task"),
)
return lastRelevantIndex !== -1
? clineMessages[lastRelevantIndex]?.ask === "completion_result"
@ -160,6 +160,50 @@ const TaskHeader = ({
return costs
}, [todos, subtaskDetails])
const aggregatedLineChanges = useMemo(() => {
const ownAdded = (currentTaskItem as any)?.linesAdded
const ownRemoved = (currentTaskItem as any)?.linesRemoved
const processedSubtasks = new Set<string>()
let childrenAdded = 0
let childrenRemoved = 0
if (Array.isArray(subtaskDetails)) {
for (const subtask of subtaskDetails) {
if (!subtask?.id || typeof subtask.id !== "string") continue
if (processedSubtasks.has(subtask.id)) continue
processedSubtasks.add(subtask.id)
if (typeof subtask.added === "number" && Number.isFinite(subtask.added)) {
childrenAdded += subtask.added
}
if (typeof subtask.removed === "number" && Number.isFinite(subtask.removed)) {
childrenRemoved += subtask.removed
}
}
}
const totalAdded = (typeof ownAdded === "number" && Number.isFinite(ownAdded) ? ownAdded : 0) + childrenAdded
const totalRemoved =
(typeof ownRemoved === "number" && Number.isFinite(ownRemoved) ? ownRemoved : 0) + childrenRemoved
const hasAdded = totalAdded > 0
const hasRemoved = totalRemoved > 0
const hasAnyLineChanges = hasAdded || hasRemoved
const formatted = [hasAdded ? `+${totalAdded}` : null, hasRemoved ? `−${totalRemoved}` : null]
.filter(Boolean)
.join(" ")
return {
totalAdded,
totalRemoved,
hasAdded,
hasRemoved,
hasAnyLineChanges,
formatted,
}
}, [currentTaskItem, subtaskDetails])
const tooltipCostData = useMemo(
() =>
getTaskHeaderCostTooltipData({
@ -313,7 +357,7 @@ const TaskHeader = ({
<StandardTooltip
content={
shouldTreatAsHasSubtasks ? (
<div>
<div className="space-y-1">
<div>
{t("chat:costs.totalWithSubtasks", {
cost: displayTotalCost.toFixed(2),
@ -329,7 +373,7 @@ const TaskHeader = ({
}
side="top"
sideOffset={8}>
<span>
<span className="tabular-nums">
${displayTotalCost.toFixed(2)}
{shouldTreatAsHasSubtasks && (
<span className="text-xs ml-1" title={t("chat:costs.includesSubtasks")}>
@ -339,6 +383,20 @@ const TaskHeader = ({
</span>
</StandardTooltip>
)}
{aggregatedLineChanges.hasAnyLineChanges && (
<span className="flex items-center gap-2 tabular-nums text-sm">
{aggregatedLineChanges.hasAdded && (
<span className="font-medium text-vscode-charts-green">
+{aggregatedLineChanges.totalAdded}
</span>
)}
{aggregatedLineChanges.hasRemoved && (
<span className="font-medium text-vscode-charts-red">
−{aggregatedLineChanges.totalRemoved}
</span>
)}
</span>
)}
</div>
{showBrowserGlobe && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
@ -514,6 +572,28 @@ const TaskHeader = ({
</tr>
)}
{aggregatedLineChanges.hasAnyLineChanges && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("common:stats.lines")}
</th>
<td className="font-light align-top">
<span className="flex items-center gap-2 tabular-nums">
{aggregatedLineChanges.hasAdded && (
<span className="font-medium text-vscode-charts-green">
+{aggregatedLineChanges.totalAdded}
</span>
)}
{aggregatedLineChanges.hasRemoved && (
<span className="font-medium text-vscode-charts-red">
−{aggregatedLineChanges.totalRemoved}
</span>
)}
</span>
</td>
</tr>
)}
{/* Size display */}
{!!currentTaskItem?.size && currentTaskItem.size > 0 && (
<tr>

View file

@ -19,6 +19,8 @@ interface TodoItem {
subtaskId?: string
tokens?: number
cost?: number
added?: number
removed?: number
}
function getTodoIcon(status: TodoStatus | null) {
@ -84,7 +86,7 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
? "text-vscode-charts-yellow"
: "text-vscode-foreground",
)}
onClick={() => setIsCollapsed((v) => !v)}>
onClick={() => setIsCollapsed((v: boolean) => !v)}>
<ListChecks className="size-3 shrink-0" />
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{isCollapsed
@ -113,6 +115,17 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
const displayCost = todo.cost ?? subtaskById?.cost
const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number"
const displayAdded = todo.added ?? subtaskById?.added
const displayRemoved = todo.removed ?? subtaskById?.removed
const hasValidSubtaskLink = typeof todo.subtaskId === "string" && todo.subtaskId.length > 0
const shouldShowLineChanges =
hasValidSubtaskLink && (Number.isFinite(displayAdded) || Number.isFinite(displayRemoved))
const hasAdded =
typeof displayAdded === "number" && Number.isFinite(displayAdded) && displayAdded > 0
const hasRemoved =
typeof displayRemoved === "number" && Number.isFinite(displayRemoved) && displayRemoved > 0
return (
<li
key={todo.id || todo.content}
@ -131,14 +144,36 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
{todo.content}
</span>
{/* Token count and cost display */}
{shouldShowCost && (
{(shouldShowCost || shouldShowLineChanges) && (
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
<span className="tabular-nums opacity-70">
{formatLargeNumber(displayTokens)}
</span>
<span className="tabular-nums min-w-[45px] text-right">
${displayCost.toFixed(2)}
</span>
{shouldShowCost && (
<>
<span className="tabular-nums opacity-70">
{formatLargeNumber(displayTokens)}
</span>
<span className="tabular-nums min-w-[45px] text-right">
${displayCost.toFixed(2)}
</span>
</>
)}
{shouldShowLineChanges && (
<span className="tabular-nums ml-2 min-w-[60px] grid grid-cols-2 items-center justify-end">
<span
className={cn(
" text-right",
hasAdded ? "font-medium text-vscode-charts-green" : "",
)}>
{hasAdded ? `+${displayAdded}` : "\u00A0"}
</span>
<span
className={cn(
" text-right",
hasRemoved ? "font-medium text-vscode-charts-red" : "",
)}>
{hasRemoved ? `−${displayRemoved}` : "\u00A0"}
</span>
</span>
)}
</span>
)}
</li>

View file

@ -34,6 +34,8 @@ describe("TodoListDisplay", () => {
name: "Task 1: Change background colour",
tokens: 95400,
cost: 0.22,
added: 10,
removed: 4,
status: "completed",
hasNestedChildren: false,
},
@ -42,6 +44,8 @@ describe("TodoListDisplay", () => {
name: "Task 2: Add timestamp to bottom",
tokens: 95000,
cost: 0.24,
added: 3,
removed: 2,
status: "completed",
hasNestedChildren: false,
},
@ -106,7 +110,9 @@ describe("TodoListDisplay", () => {
// The pending task has no subtaskId, should not show cost
const listItems = screen.getAllByRole("listitem")
const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task"))
const pendingItem = listItems.find((item: HTMLElement) =>
item.textContent?.includes("Task 3: Pending task"),
)
expect(pendingItem).toBeDefined()
expect(pendingItem?.textContent).not.toContain("$")
})
@ -177,6 +183,96 @@ describe("TodoListDisplay", () => {
})
})
describe("line change display", () => {
it("uses todo.added/todo.removed when present", () => {
const todosWithDirectLineChanges = [
{
id: "1",
content: "Task 1: Change background colour",
status: "completed",
subtaskId: "subtask-1",
added: 7,
removed: 9,
},
]
render(<TodoListDisplay todos={todosWithDirectLineChanges} subtaskDetails={subtaskDetails} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Line changes are rendered as separate colored spans
expect(screen.getByText("+7")).toBeInTheDocument()
expect(screen.getByText("−9")).toBeInTheDocument()
})
it("falls back to subtaskDetails when todo added/removed are missing", () => {
const todosMissingDirectLineChanges = [
{
id: "1",
content: "Task 1: Change background colour",
status: "completed",
subtaskId: "subtask-1",
},
]
render(<TodoListDisplay todos={todosMissingDirectLineChanges} subtaskDetails={subtaskDetails} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Line changes are rendered as separate colored spans
expect(screen.getByText("+10")).toBeInTheDocument()
expect(screen.getByText("−4")).toBeInTheDocument()
})
it("hides line deltas when no data available (no subtaskId)", () => {
const todosNoSubtaskLink = [{ id: "1", content: "No link todo", status: "completed" }]
render(<TodoListDisplay todos={todosNoSubtaskLink} subtaskDetails={subtaskDetails} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument()
expect(screen.queryByText(/−\d+/)).not.toBeInTheDocument()
})
it("hides line deltas when all values are undefined (subtaskId present)", () => {
const todosWithLinkButNoLineChanges = [
{
id: "1",
content: "Task 1: Change background colour",
status: "completed",
subtaskId: "subtask-1",
},
]
const subtaskDetailsWithoutLineChanges: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Task 1: Change background colour",
tokens: 95400,
cost: 0.22,
status: "completed",
hasNestedChildren: false,
} as unknown as SubtaskDetail,
]
render(
<TodoListDisplay
todos={todosWithLinkButNoLineChanges}
subtaskDetails={subtaskDetailsWithoutLineChanges}
/>,
)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument()
expect(screen.queryByText(/−\d+/)).not.toBeInTheDocument()
})
})
describe("click handler", () => {
it("should call onSubtaskClick when a todo with subtaskId is clicked", () => {
const onSubtaskClick = vi.fn()

View file

@ -108,7 +108,10 @@
"awaiting_child": "Esperant la tasca filla {{childId}}"
},
"costs": {
"own": "Propi",
"own": "Principal",
"subtasks": "Subtasques"
},
"stats": {
"lines": "Línies"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Warte auf Unteraufgabe {{childId}}"
},
"costs": {
"own": "Eigen",
"own": "Haupt",
"subtasks": "Unteraufgaben"
},
"stats": {
"lines": "Zeilen"
}
}

View file

@ -6,6 +6,7 @@
"collapse": "Collapse task",
"seeMore": "See more",
"seeLess": "See less",
"lineChanges": "Lines:",
"tokens": "Tokens",
"cache": "Cache",
"apiCost": "API Cost",

View file

@ -108,7 +108,10 @@
"awaiting_child": "Awaiting child task {{childId}}"
},
"costs": {
"own": "Own",
"own": "Main",
"subtasks": "Subtasks"
},
"stats": {
"lines": "Lines"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Esperando tarea secundaria {{childId}}"
},
"costs": {
"own": "Propio",
"own": "Principal",
"subtasks": "Subtareas"
},
"stats": {
"lines": "Líneas"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "En attente de la tâche enfant {{childId}}"
},
"costs": {
"own": "Propre",
"own": "Principal",
"subtasks": "Sous-tâches"
},
"stats": {
"lines": "Lignes"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में"
},
"costs": {
"own": "स्वयं",
"own": "मुख्य",
"subtasks": "उपकार्य"
},
"stats": {
"lines": "पंक्तियाँ"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Menunggu tugas anak {{childId}}"
},
"costs": {
"own": "Sendiri",
"own": "Utama",
"subtasks": "Subtugas"
},
"stats": {
"lines": "Baris"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "In attesa dell'attività figlia {{childId}}"
},
"costs": {
"own": "Proprio",
"own": "Principale",
"subtasks": "Sottoattività"
},
"stats": {
"lines": "Linee"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "子タスク{{childId}}を待機中"
},
"costs": {
"own": "自身",
"own": "メイン",
"subtasks": "サブタスク"
},
"stats": {
"lines": "行"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "하위 작업 {{childId}} 대기 중"
},
"costs": {
"own": "자체",
"own": "메인",
"subtasks": "하위작업"
},
"stats": {
"lines": "라인"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Wachten op kindtaak {{childId}}"
},
"costs": {
"own": "Eigen",
"own": "Hoofd",
"subtasks": "Subtaken"
},
"stats": {
"lines": "Regels"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}"
},
"costs": {
"own": "Własne",
"own": "Główne",
"subtasks": "Podzadania"
},
"stats": {
"lines": "Linie"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Aguardando tarefa filha {{childId}}"
},
"costs": {
"own": "Próprio",
"own": "Principal",
"subtasks": "Subtarefas"
},
"stats": {
"lines": "Linhas"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Ожидание дочерней задачи {{childId}}"
},
"costs": {
"own": "Собственные",
"own": "Основная",
"subtasks": "Подзадачи"
},
"stats": {
"lines": "Строки"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "{{childId}} alt görevi bekleniyor"
},
"costs": {
"own": "Kendi",
"own": "Ana",
"subtasks": "Alt görevler"
},
"stats": {
"lines": "Satırlar"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "Đang chờ nhiệm vụ con {{childId}}"
},
"costs": {
"own": "Riêng",
"own": "Chính",
"subtasks": "Nhiệm vụ con"
},
"stats": {
"lines": "Dòng"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "等待子任务 {{childId}}"
},
"costs": {
"own": "自身",
"own": "主要",
"subtasks": "子任务"
},
"stats": {
"lines": "行"
}
}

View file

@ -108,7 +108,10 @@
"awaiting_child": "等待子工作 {{childId}}"
},
"costs": {
"own": "自身",
"own": "主要",
"subtasks": "子工作"
},
"stats": {
"lines": "行"
}
}

View file

@ -5,6 +5,10 @@ export type SubtaskDetail = {
name: string
/** tokensIn + tokensOut */
tokens: number
/** Total lines added across the subtask */
added: number
/** Total lines removed across the subtask */
removed: number
/** Aggregated total cost */
cost: number
status: "active" | "completed" | "delegated"