From 235cffad3b217f82d9441792b516ab5c0fcd664f Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 23:01:19 -0500 Subject: [PATCH] feat: track line changes for delegated subtasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/types/src/history.ts | 2 + packages/types/src/todo.ts | 2 + .../__tests__/taskMetadata.spec.ts | 125 ++++++++++++++++++ src/core/task-persistence/taskMetadata.ts | 68 ++++++++++ src/core/webview/ClineProvider.ts | 2 + .../__tests__/aggregateTaskCosts.spec.ts | 86 ++++++++++++ src/core/webview/aggregateTaskCosts.ts | 46 ++++++- webview-ui/src/components/chat/TaskHeader.tsx | 86 +++++++++++- .../src/components/chat/TodoListDisplay.tsx | 51 +++++-- .../chat/__tests__/TodoListDisplay.spec.tsx | 98 +++++++++++++- webview-ui/src/i18n/locales/ca/common.json | 5 +- webview-ui/src/i18n/locales/de/common.json | 5 +- webview-ui/src/i18n/locales/en/chat.json | 1 + webview-ui/src/i18n/locales/en/common.json | 5 +- webview-ui/src/i18n/locales/es/common.json | 5 +- webview-ui/src/i18n/locales/fr/common.json | 5 +- webview-ui/src/i18n/locales/hi/common.json | 5 +- webview-ui/src/i18n/locales/id/common.json | 5 +- webview-ui/src/i18n/locales/it/common.json | 5 +- webview-ui/src/i18n/locales/ja/common.json | 5 +- webview-ui/src/i18n/locales/ko/common.json | 5 +- webview-ui/src/i18n/locales/nl/common.json | 5 +- webview-ui/src/i18n/locales/pl/common.json | 5 +- webview-ui/src/i18n/locales/pt-BR/common.json | 5 +- webview-ui/src/i18n/locales/ru/common.json | 5 +- webview-ui/src/i18n/locales/tr/common.json | 5 +- webview-ui/src/i18n/locales/vi/common.json | 5 +- webview-ui/src/i18n/locales/zh-CN/common.json | 5 +- webview-ui/src/i18n/locales/zh-TW/common.json | 5 +- webview-ui/src/types/subtasks.ts | 4 + 30 files changed, 629 insertions(+), 32 deletions(-) create mode 100644 src/core/task-persistence/__tests__/taskMetadata.spec.ts diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index b4d84cb9a5..06e246e0dc 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -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(), diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts index 0530f92054..328beeb359 100644 --- a/packages/types/src/todo.ts +++ b/packages/types/src/todo.ts @@ -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 diff --git a/src/core/task-persistence/__tests__/taskMetadata.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.spec.ts new file mode 100644 index 0000000000..52f8ec2a0d --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.spec.ts @@ -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() + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index cf8d9adb52..92607595a9 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -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 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, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d6aab711ef..d287e1c338 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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(), diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index eeffcebf47..000460471a 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -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, }, } diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index f85d7176a9..2d163be9c9 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -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, }) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index c6890e884e..a1f50f8560 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -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() + 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 = ({ +
{t("chat:costs.totalWithSubtasks", { cost: displayTotalCost.toFixed(2), @@ -329,7 +373,7 @@ const TaskHeader = ({ } side="top" sideOffset={8}> - + ${displayTotalCost.toFixed(2)} {shouldTreatAsHasSubtasks && ( @@ -339,6 +383,20 @@ const TaskHeader = ({ )} + {aggregatedLineChanges.hasAnyLineChanges && ( + + {aggregatedLineChanges.hasAdded && ( + + +{aggregatedLineChanges.totalAdded} + + )} + {aggregatedLineChanges.hasRemoved && ( + + −{aggregatedLineChanges.totalRemoved} + + )} + + )}
{showBrowserGlobe && (
e.stopPropagation()}> @@ -514,6 +572,28 @@ const TaskHeader = ({ )} + {aggregatedLineChanges.hasAnyLineChanges && ( + + + {t("common:stats.lines")} + + + + {aggregatedLineChanges.hasAdded && ( + + +{aggregatedLineChanges.totalAdded} + + )} + {aggregatedLineChanges.hasRemoved && ( + + −{aggregatedLineChanges.totalRemoved} + + )} + + + + )} + {/* Size display */} {!!currentTaskItem?.size && currentTaskItem.size > 0 && ( diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 684ff82feb..bbc187bacb 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -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)}> {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 (
  • {/* Token count and cost display */} - {shouldShowCost && ( + {(shouldShowCost || shouldShowLineChanges) && ( - - {formatLargeNumber(displayTokens)} - - - ${displayCost.toFixed(2)} - + {shouldShowCost && ( + <> + + {formatLargeNumber(displayTokens)} + + + ${displayCost.toFixed(2)} + + + )} + {shouldShowLineChanges && ( + + + {hasAdded ? `+${displayAdded}` : "\u00A0"} + + + {hasRemoved ? `−${displayRemoved}` : "\u00A0"} + + + )} )}
  • diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index f5babe983c..d4a8d349f7 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -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() + + // 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() + + // 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() + + // 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( + , + ) + + // 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() diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 56e9a3745e..222cc6ffe7 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Esperant la tasca filla {{childId}}" }, "costs": { - "own": "Propi", + "own": "Principal", "subtasks": "Subtasques" + }, + "stats": { + "lines": "Línies" } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index ab8bd6d240..e8f01f5683 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Warte auf Unteraufgabe {{childId}}" }, "costs": { - "own": "Eigen", + "own": "Haupt", "subtasks": "Unteraufgaben" + }, + "stats": { + "lines": "Zeilen" } } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 3ab2c037af..36c19e54ef 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -6,6 +6,7 @@ "collapse": "Collapse task", "seeMore": "See more", "seeLess": "See less", + "lineChanges": "Lines:", "tokens": "Tokens", "cache": "Cache", "apiCost": "API Cost", diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 981eaeec75..32d9f6f41f 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Awaiting child task {{childId}}" }, "costs": { - "own": "Own", + "own": "Main", "subtasks": "Subtasks" + }, + "stats": { + "lines": "Lines" } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 03455b7cad..465243d5bc 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Esperando tarea secundaria {{childId}}" }, "costs": { - "own": "Propio", + "own": "Principal", "subtasks": "Subtareas" + }, + "stats": { + "lines": "Líneas" } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index def93ad6c5..979c204285 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 076530e6b0..3f2f1a5728 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -108,7 +108,10 @@ "awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में" }, "costs": { - "own": "स्वयं", + "own": "मुख्य", "subtasks": "उपकार्य" + }, + "stats": { + "lines": "पंक्तियाँ" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index a65295f28d..2c2565ff6c 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Menunggu tugas anak {{childId}}" }, "costs": { - "own": "Sendiri", + "own": "Utama", "subtasks": "Subtugas" + }, + "stats": { + "lines": "Baris" } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 9b801628f4..943c350e56 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -108,7 +108,10 @@ "awaiting_child": "In attesa dell'attività figlia {{childId}}" }, "costs": { - "own": "Proprio", + "own": "Principale", "subtasks": "Sottoattività" + }, + "stats": { + "lines": "Linee" } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index b3b9d462e0..a8909bbdeb 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -108,7 +108,10 @@ "awaiting_child": "子タスク{{childId}}を待機中" }, "costs": { - "own": "自身", + "own": "メイン", "subtasks": "サブタスク" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index d7120e2520..472891d0d5 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -108,7 +108,10 @@ "awaiting_child": "하위 작업 {{childId}} 대기 중" }, "costs": { - "own": "자체", + "own": "메인", "subtasks": "하위작업" + }, + "stats": { + "lines": "라인" } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index ec6cf89ccb..e62f045481 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Wachten op kindtaak {{childId}}" }, "costs": { - "own": "Eigen", + "own": "Hoofd", "subtasks": "Subtaken" + }, + "stats": { + "lines": "Regels" } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 419aa83af1..9b5ecbfe51 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}" }, "costs": { - "own": "Własne", + "own": "Główne", "subtasks": "Podzadania" + }, + "stats": { + "lines": "Linie" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 4990796976..4f1ebf94d9 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Aguardando tarefa filha {{childId}}" }, "costs": { - "own": "Próprio", + "own": "Principal", "subtasks": "Subtarefas" + }, + "stats": { + "lines": "Linhas" } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index f66384a693..47bab140c8 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Ожидание дочерней задачи {{childId}}" }, "costs": { - "own": "Собственные", + "own": "Основная", "subtasks": "Подзадачи" + }, + "stats": { + "lines": "Строки" } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index db9e991cd5..81dca46196 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -108,7 +108,10 @@ "awaiting_child": "{{childId}} alt görevi bekleniyor" }, "costs": { - "own": "Kendi", + "own": "Ana", "subtasks": "Alt görevler" + }, + "stats": { + "lines": "Satırlar" } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 57eb31fafa..d3b37fb35c 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 10df089333..cf7847a8d1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -108,7 +108,10 @@ "awaiting_child": "等待子任务 {{childId}}" }, "costs": { - "own": "自身", + "own": "主要", "subtasks": "子任务" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index da47dec72b..435bda21cb 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -108,7 +108,10 @@ "awaiting_child": "等待子工作 {{childId}}" }, "costs": { - "own": "自身", + "own": "主要", "subtasks": "子工作" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/types/subtasks.ts b/webview-ui/src/types/subtasks.ts index a4cd97ed4f..e0d2bd7322 100644 --- a/webview-ui/src/types/subtasks.ts +++ b/webview-ui/src/types/subtasks.ts @@ -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"