refactor: remove line change tracking from subtask system

Remove the line change tracking feature (+added/-removed) that was
displaying git diff statistics for delegated subtasks. This feature
added complexity across the entire stack without sufficient value.

Changes:
- Remove DiffStats type and line change fields from schemas
- Remove extraction, aggregation, and persistence logic
- Remove UI displays in TaskHeader and TodoListDisplay
- Remove all associated tests and utilities
- Preserve all cost and token tracking functionality

Impact:
- 20 files modified
- 1,154 net lines removed
- All tests passing
- TypeScript compilation successful

Original feature from PR #10765
This commit is contained in:
Toray Altas 2026-01-21 09:35:40 -05:00
parent e8bcb8cf04
commit 5750f0f340
20 changed files with 22 additions and 1176 deletions

View file

@ -16,8 +16,6 @@ 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,8 +18,6 @@ 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

@ -36,11 +36,3 @@ export interface GenerateImageParams {
path: string
image?: string
}
/**
* Statistics about code changes from a diff operation
*/
export interface DiffStats {
added: number
removed: number
}

View file

@ -188,19 +188,11 @@ export interface ExtensionMessage {
totalCost: number
ownCost: number
childrenCost: number
ownAdded?: number
ownRemoved?: number
childrenAdded?: number
childrenRemoved?: number
totalAdded?: number
totalRemoved?: number
childDetails?: {
id: string
name: string
tokens: number
cost: number
added: number
removed: number
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean
}[]

View file

@ -1,125 +1,3 @@
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()
})
})
// This file previously tested line change tracking in taskMetadata,
// which has been removed as part of eliminating line change tracking.
// The file is kept as a placeholder for future taskMetadata tests.

View file

@ -8,7 +8,6 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { findLastIndex } from "../../shared/array"
import { getTaskDirectoryPath } from "../../utils/storage"
import { getLineStatsFromToolApprovalMessages } from "../../shared/messageUtils"
import { t } from "../../i18n"
const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
@ -56,8 +55,6 @@ 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
@ -96,12 +93,6 @@ 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.
@ -124,8 +115,6 @@ 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

@ -32,12 +32,7 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
const historyHasMetadata =
Array.isArray(previousFromHistory) &&
previousFromHistory.some(
(t) =>
t?.subtaskId !== undefined ||
t?.tokens !== undefined ||
t?.cost !== undefined ||
t?.added !== undefined ||
t?.removed !== undefined,
(t) => t?.subtaskId !== undefined || t?.tokens !== undefined || t?.cost !== undefined,
)
const previousTodos: TodoItem[] =
@ -86,8 +81,6 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
subtaskId: t.subtaskId,
tokens: t.tokens,
cost: t.cost,
added: t.added,
removed: t.removed,
}))
const approvalMsg = JSON.stringify({
@ -228,7 +221,7 @@ function normalizeStatus(status: string | undefined): TodoStatus {
}
/**
* Preserve metadata (subtaskId, tokens, cost, added, removed) from previous todos onto next todos.
* Preserve metadata (subtaskId, tokens, cost) from previous todos onto next todos.
*
* Matching strategy (in priority order):
* 1. **Subtask ID match**: If the next todo has a `subtaskId`, match against previous todos with
@ -347,8 +340,6 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]):
subtaskId: next.subtaskId ?? matchedPrev.subtaskId,
tokens: next.tokens ?? matchedPrev.tokens,
cost: next.cost ?? matchedPrev.cost,
added: next.added ?? matchedPrev.added,
removed: next.removed ?? matchedPrev.removed,
}
continue
}
@ -383,8 +374,6 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]):
subtaskId: next.subtaskId ?? prev.subtaskId,
tokens: next.tokens ?? prev.tokens,
cost: next.cost ?? prev.cost,
added: next.added ?? prev.added,
removed: next.removed ?? prev.removed,
}
usedPreviousIndices.add(i)
}
@ -417,8 +406,6 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]):
subtaskId: targetNext.subtaskId ?? orphanedPrev.subtaskId,
tokens: targetNext.tokens ?? orphanedPrev.tokens,
cost: targetNext.cost ?? orphanedPrev.cost,
added: targetNext.added ?? orphanedPrev.added,
removed: targetNext.removed ?? orphanedPrev.removed,
}
result[targetNextIndex] = updatedTarget
@ -518,8 +505,6 @@ function tryParseTodoItemsJson(raw: string): { parsed?: TodoItem[]; error?: stri
...(typeof t.subtaskId === "string" ? { subtaskId: t.subtaskId } : {}),
...(typeof t.tokens === "number" ? { tokens: t.tokens } : {}),
...(typeof t.cost === "number" ? { cost: t.cost } : {}),
...(typeof t.added === "number" ? { added: t.added } : {}),
...(typeof t.removed === "number" ? { removed: t.removed } : {}),
}
normalized.push(normalizedItem)

View file

@ -263,7 +263,7 @@ describe("UpdateTodoListTool.execute", () => {
setPendingTodoList([])
})
it("should preserve per-row metadata (subtaskId/tokens/cost/added/removed) when only statuses change (bulk markdown rewrite)", async () => {
it("should preserve per-row metadata (subtaskId/tokens/cost) when only statuses change (bulk markdown rewrite)", async () => {
/**
* Regression test: a bulk markdown rewrite often changes the derived todo `id`
* (since [`parseMarkdownChecklist()`](../UpdateTodoListTool.ts:337) hashes
@ -280,8 +280,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: `subtask-${idx + 1}`,
tokens: 1000 + idx,
cost: 0.01 * (idx + 1),
added: 10 * (idx + 1),
removed: idx,
}))
const task = {
@ -312,8 +310,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 1000,
cost: 0.01,
added: 10,
removed: 0,
}),
)
@ -324,8 +320,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-2",
tokens: 1001,
cost: 0.02,
added: 20,
removed: 1,
}),
)
@ -336,8 +330,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-3",
tokens: 1002,
cost: 0.03,
added: 30,
removed: 2,
}),
)
@ -348,8 +340,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-4",
tokens: 1003,
cost: 0.04,
added: 40,
removed: 3,
}),
)
})
@ -363,8 +353,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: `subtask-${idx + 1}`,
tokens: 100 + idx,
cost: 0.01 * (idx + 1),
added: 10 * (idx + 1),
removed: idx,
}))
const task = {
@ -394,8 +382,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 100,
cost: 0.01,
added: 10,
removed: 0,
}),
)
expect(task.todoList[1]).toEqual(
@ -405,8 +391,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-2",
tokens: 101,
cost: 0.02,
added: 20,
removed: 1,
}),
)
expect(task.todoList[2]).toEqual(
@ -416,8 +400,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-3",
tokens: 102,
cost: 0.03,
added: 30,
removed: 2,
}),
)
})
@ -431,8 +413,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 111,
cost: 0.11,
added: 11,
removed: 1,
},
{
id: "id-2",
@ -441,8 +421,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-2",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
},
]
@ -482,8 +460,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-2",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
}),
)
@ -495,8 +471,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 111,
cost: 0.11,
added: 11,
removed: 1,
}),
)
})
@ -547,50 +521,6 @@ describe("UpdateTodoListTool.execute", () => {
)
})
it("should treat added/removed as metadata and prefer history todos when present", async () => {
const md = "[ ] Task 1"
const previousFromMemory = parseMarkdownChecklist(md)
const previousFromHistory: TodoItem[] = previousFromMemory.map((t) => ({
...t,
added: 10,
removed: 3,
}))
const task = {
todoList: previousFromMemory,
clineMessages: [
{
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos: previousFromHistory }),
},
],
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
didToolFailInCurrentTurn: false,
say: vi.fn(),
} as any
const tool = new UpdateTodoListTool()
await tool.execute({ todos: md }, task, {
pushToolResult: vi.fn(),
handleError: vi.fn(),
askApproval: vi.fn().mockResolvedValue(true),
removeClosingTag: vi.fn(),
toolProtocol: "xml",
})
expect(task.todoList).toHaveLength(1)
expect(task.todoList[0]).toEqual(
expect.objectContaining({
content: "Task 1",
added: 10,
removed: 3,
}),
)
})
it("should preserve metadata by subtaskId even when content (and derived id) changes", async () => {
// This test simulates the "user edited todo list" flow. The tool re-applies metadata
// after approval; subtaskId should be used as the primary match when content/id changes.
@ -601,8 +531,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 123,
cost: 0.01,
added: 10,
removed: 3,
}))
const task = {
@ -621,7 +549,7 @@ describe("UpdateTodoListTool.execute", () => {
content: "New text",
status: "completed",
subtaskId: "subtask-1",
// tokens/cost/added/removed intentionally omitted to verify preservation
// tokens/cost intentionally omitted to verify preservation
},
]
@ -646,46 +574,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: "subtask-1",
tokens: 123,
cost: 0.01,
added: 10,
removed: 3,
}),
)
})
it("should preserve added/removed through normalization", async () => {
const md = "[x] Task 1"
const previousFromMemory: TodoItem[] = parseMarkdownChecklist("[ ] Task 1").map((t) => ({
...t,
added: 10,
removed: 3,
}))
const task = {
todoList: previousFromMemory,
clineMessages: [],
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
didToolFailInCurrentTurn: false,
say: vi.fn(),
} as any
const tool = new UpdateTodoListTool()
await tool.execute({ todos: md }, task, {
pushToolResult: vi.fn(),
handleError: vi.fn(),
askApproval: vi.fn().mockResolvedValue(true),
removeClosingTag: vi.fn(),
toolProtocol: "xml",
})
expect(task.todoList).toHaveLength(1)
expect(task.todoList[0]).toEqual(
expect.objectContaining({
content: "Task 1",
status: "completed",
added: 10,
removed: 3,
}),
)
})
@ -700,8 +588,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "pending",
tokens: 111,
cost: 0.11,
added: 11,
removed: 1,
},
{
id: "legacy-2",
@ -709,8 +595,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "pending",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
},
]
@ -743,8 +627,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "completed",
tokens: 111,
cost: 0.11,
added: 11,
removed: 1,
}),
)
expect(task2).toEqual(
@ -753,8 +635,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "pending",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
}),
)
})
@ -769,8 +649,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: delegatedSubtaskId,
tokens: 1234,
cost: 0.12,
added: 10,
removed: 2,
},
]
@ -804,8 +682,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: delegatedSubtaskId,
tokens: 1234,
cost: 0.12,
added: 10,
removed: 2,
}),
)
@ -819,8 +695,6 @@ describe("UpdateTodoListTool.execute", () => {
expect(task.todoList[1].subtaskId).toBeUndefined()
expect(task.todoList[1].tokens).toBeUndefined()
expect(task.todoList[1].cost).toBeUndefined()
expect(task.todoList[1].added).toBeUndefined()
expect(task.todoList[1].removed).toBeUndefined()
})
it("should carry forward metadata from unmatched delegated todos even when the previous id is non-synthetic (sequential updates)", async () => {
@ -833,8 +707,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: delegatedSubtaskId,
tokens: 1234,
cost: 0.12,
added: 10,
removed: 2,
},
{
id: "other-1",
@ -871,8 +743,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: delegatedSubtaskId,
tokens: 1234,
cost: 0.12,
added: 10,
removed: 2,
}),
)
// Ensure the carried-over todo now has a non-synthetic ID (this is the regression scenario).
@ -897,8 +767,6 @@ describe("UpdateTodoListTool.execute", () => {
subtaskId: delegatedSubtaskId,
tokens: 1234,
cost: 0.12,
added: 10,
removed: 2,
}),
)
@ -912,9 +780,7 @@ describe("UpdateTodoListTool.execute", () => {
const md = "[x] Task 1\n[ ] Task 2" // status changes for Task 1 -> derived id changes
const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t) =>
t.content === "Task 1"
? { ...t, tokens: 111, cost: 0.11, added: 11, removed: 1 }
: { ...t, tokens: 222, cost: 0.22, added: 22, removed: 2 },
t.content === "Task 1" ? { ...t, tokens: 111, cost: 0.11 } : { ...t, tokens: 222, cost: 0.22 },
)
const task = {
@ -946,8 +812,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "completed",
tokens: 111,
cost: 0.11,
added: 11,
removed: 1,
}),
)
@ -957,8 +821,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "pending",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
}),
)
})
@ -968,9 +830,7 @@ describe("UpdateTodoListTool.execute", () => {
const md = "[x] Task 1 (updated)\n[ ] Task 2"
const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t) =>
t.content === "Task 1"
? { ...t, tokens: 111, cost: 0.11, added: 11, removed: 1 }
: { ...t, tokens: 222, cost: 0.22, added: 22, removed: 2 },
t.content === "Task 1" ? { ...t, tokens: 111, cost: 0.11 } : { ...t, tokens: 222, cost: 0.22 },
)
const task = {
@ -1004,8 +864,6 @@ describe("UpdateTodoListTool.execute", () => {
)
expect(updated?.tokens).toBeUndefined()
expect(updated?.cost).toBeUndefined()
expect(updated?.added).toBeUndefined()
expect(updated?.removed).toBeUndefined()
expect(task2).toEqual(
expect.objectContaining({
@ -1013,8 +871,6 @@ describe("UpdateTodoListTool.execute", () => {
status: "pending",
tokens: 222,
cost: 0.22,
added: 22,
removed: 2,
}),
)
})

View file

@ -3440,8 +3440,6 @@ export class ClineProvider
linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0)
linkedTodo.cost = childHistoryItem?.totalCost || 0
linkedTodo.added = childHistoryItem?.linesAdded || 0
linkedTodo.removed = childHistoryItem?.linesRemoved || 0
this.log(
`[TODO-DEBUG] reopenParentFromDelegation persisting system_update_todos ${JSON.stringify({

View file

@ -148,8 +148,6 @@ describe("ClineProvider.reopenParentFromDelegation() writeback", () => {
tokensIn: 10,
tokensOut: 5,
totalCost: 0.123,
linesAdded: 7,
linesRemoved: 2,
} as unknown as HistoryItem,
}
@ -212,8 +210,6 @@ describe("ClineProvider.reopenParentFromDelegation() writeback", () => {
subtaskId: child2TaskId,
tokens: 15,
cost: 0.123,
added: 7,
removed: 2,
}),
)
@ -228,8 +224,6 @@ describe("ClineProvider.reopenParentFromDelegation() writeback", () => {
)
expect(updatedChild1Row?.tokens).toBeUndefined()
expect(updatedChild1Row?.cost).toBeUndefined()
expect(updatedChild1Row?.added).toBeUndefined()
expect(updatedChild1Row?.removed).toBeUndefined()
expect(updatedPrepRow).toEqual(expect.objectContaining({ id: "t1b", content: "Prep", status: "completed" }))
expect((updatedPrepRow as any)?.subtaskId).toBeUndefined()

View file

@ -53,15 +53,11 @@ 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,
}
@ -73,12 +69,6 @@ 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()
@ -124,22 +114,16 @@ 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,
}
@ -152,22 +136,12 @@ 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"]
@ -175,8 +149,6 @@ 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 () => {
@ -184,15 +156,11 @@ 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,
}
@ -205,8 +173,6 @@ 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"))
@ -244,15 +210,11 @@ describe("aggregateTaskCostsRecursive", () => {
root: {
id: "root",
totalCost: 1.0,
linesAdded: 1,
linesRemoved: 0,
childIds: ["child-ok", "child-missing"],
} as unknown as HistoryItem,
"child-ok": {
id: "child-ok",
totalCost: 0.5,
linesAdded: 3,
linesRemoved: 2,
childIds: [],
} as unknown as HistoryItem,
}
@ -285,16 +247,10 @@ describe("aggregateTaskCostsRecursive", () => {
expect(result.ownCost).toBe(1.0)
expect(result.childrenCost).toBe(0.5)
expect(result.totalCost).toBe(1.5)
expect(result.childrenAdded).toBe(3)
expect(result.childrenRemoved).toBe(2)
expect(result.totalAdded).toBe(4)
expect(result.totalRemoved).toBe(2)
const childOk = result.childBreakdown?.["child-ok"]
expect(childOk).toBeDefined()
expect(childOk!.totalCost).toBe(0.5)
expect(childOk!.totalAdded).toBe(3)
expect(childOk!.totalRemoved).toBe(2)
// Missing child should not crash aggregation.
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task child-missing not found"))
@ -435,23 +391,11 @@ 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,
},
}
@ -483,8 +427,6 @@ 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)
@ -493,8 +435,6 @@ 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
})
@ -507,12 +447,6 @@ describe("buildSubtaskDetails", () => {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -542,12 +476,6 @@ describe("buildSubtaskDetails", () => {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -575,23 +503,11 @@ 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,
},
}
@ -630,12 +546,6 @@ describe("buildSubtaskDetails", () => {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
},
}
@ -662,12 +572,6 @@ 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,8 +8,6 @@ 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
}
@ -18,12 +16,6 @@ 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
@ -51,12 +43,6 @@ export async function aggregateTaskCostsRecursive(
ownCost: 0,
childrenCost: 0,
totalCost: 0,
ownAdded: 0,
ownRemoved: 0,
childrenAdded: 0,
childrenRemoved: 0,
totalAdded: 0,
totalRemoved: 0,
}
}
visited.add(taskId)
@ -69,21 +55,11 @@ export async function aggregateTaskCostsRecursive(
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
@ -95,8 +71,6 @@ 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
}
}
@ -105,12 +79,6 @@ export async function aggregateTaskCostsRecursive(
ownCost,
childrenCost,
totalCost: ownCost + childrenCost,
ownAdded,
ownRemoved,
childrenAdded,
childrenRemoved,
totalAdded: ownAdded + childrenAdded,
totalRemoved: ownRemoved + childrenRemoved,
childBreakdown,
}
@ -144,8 +112,6 @@ 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

@ -1,259 +1,3 @@
// npx vitest run src/shared/__tests__/messageUtils.spec.ts
import type { ClineMessage } from "@roo-code/types"
import { getLineStatsFromToolApprovalMessages } from "../messageUtils"
describe("messageUtils", () => {
describe("getLineStatsFromToolApprovalMessages", () => {
it("should return zero stats for empty messages array", () => {
const result = getLineStatsFromToolApprovalMessages([])
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
it("should ignore non-tool ask messages", () => {
const messages: ClineMessage[] = [
{ type: "say", say: "text", text: "hello", ts: 1000 },
{ type: "ask", ask: "followup", text: '{"diffStats":{"added":10,"removed":5}}', ts: 1001 },
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
it("should ignore partial tool ask messages", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
partial: true,
text: '{"diffStats":{"added":10,"removed":5}}',
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
it("should extract stats from a valid tool ask with diffStats", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":10,"removed":5}}',
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 10,
linesRemoved: 5,
foundAnyStats: true,
})
})
it("should extract stats from batchDiffs array", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: JSON.stringify({
batchDiffs: [{ diffStats: { added: 5, removed: 3 } }, { diffStats: { added: 15, removed: 7 } }],
}),
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 20,
linesRemoved: 10,
foundAnyStats: true,
})
})
it("should handle both diffStats and batchDiffs in the same message", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: JSON.stringify({
diffStats: { added: 10, removed: 5 },
batchDiffs: [{ diffStats: { added: 5, removed: 3 } }, { diffStats: { added: 15, removed: 7 } }],
}),
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 30,
linesRemoved: 15,
foundAnyStats: true,
})
})
it("should accumulate stats from multiple messages", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":10,"removed":5}}',
ts: 1000,
},
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":20,"removed":15}}',
ts: 1001,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 30,
linesRemoved: 20,
foundAnyStats: true,
})
})
it("should ignore messages with invalid JSON", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: "not valid json",
ts: 1000,
},
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":10,"removed":5}}',
ts: 1001,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 10,
linesRemoved: 5,
foundAnyStats: true,
})
})
it("should ignore messages with empty or missing text", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: "",
ts: 1000,
},
{
type: "ask",
ask: "tool",
ts: 1001,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
it("should ignore invalid diffStats (non-finite numbers)", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":"10","removed":5}}',
ts: 1000,
},
{
type: "ask",
ask: "tool",
text: '{"diffStats":{"added":10,"removed":null}}',
ts: 1001,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
it("should skip invalid items in batchDiffs array", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: JSON.stringify({
batchDiffs: [
{ diffStats: { added: 5, removed: 3 } },
null,
{ diffStats: { added: "invalid", removed: 7 } },
{ diffStats: { added: 15, removed: 10 } },
],
}),
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 20,
linesRemoved: 13,
foundAnyStats: true,
})
})
it("should handle messages with no diffStats field", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "tool",
text: '{"someOtherField":"value"}',
ts: 1000,
},
]
const result = getLineStatsFromToolApprovalMessages(messages)
expect(result).toEqual({
linesAdded: 0,
linesRemoved: 0,
foundAnyStats: false,
})
})
})
})
// This file previously tested getLineStatsFromToolApprovalMessages,
// which has been removed as part of eliminating line change tracking.
// The file is kept as a placeholder for future messageUtils tests.

View file

@ -1,6 +1,6 @@
// npx vitest run src/shared/__tests__/typeGuards.spec.ts
import { isFiniteNumber, isDiffStats } from "../typeGuards"
import { isFiniteNumber } from "../typeGuards"
describe("typeGuards", () => {
describe("isFiniteNumber", () => {
@ -27,44 +27,4 @@ describe("typeGuards", () => {
expect(isFiniteNumber([])).toBe(false)
})
})
describe("isDiffStats", () => {
it("should return true for valid DiffStats objects", () => {
expect(isDiffStats({ added: 0, removed: 0 })).toBe(true)
expect(isDiffStats({ added: 10, removed: 5 })).toBe(true)
expect(isDiffStats({ added: 100, removed: 200 })).toBe(true)
})
it("should return false for objects with non-finite numbers", () => {
expect(isDiffStats({ added: Infinity, removed: 5 })).toBe(false)
expect(isDiffStats({ added: 10, removed: NaN })).toBe(false)
expect(isDiffStats({ added: NaN, removed: Infinity })).toBe(false)
})
it("should return false for objects with non-number properties", () => {
expect(isDiffStats({ added: "10", removed: 5 })).toBe(false)
expect(isDiffStats({ added: 10, removed: "5" })).toBe(false)
expect(isDiffStats({ added: null, removed: 5 })).toBe(false)
expect(isDiffStats({ added: 10, removed: undefined })).toBe(false)
})
it("should return false for objects missing required properties", () => {
expect(isDiffStats({ added: 10 })).toBe(false)
expect(isDiffStats({ removed: 5 })).toBe(false)
expect(isDiffStats({})).toBe(false)
})
it("should return false for non-object types", () => {
expect(isDiffStats(null)).toBe(false)
expect(isDiffStats(undefined)).toBe(false)
expect(isDiffStats("string")).toBe(false)
expect(isDiffStats(42)).toBe(false)
expect(isDiffStats([])).toBe(false)
expect(isDiffStats(true)).toBe(false)
})
it("should ignore extra properties on valid objects", () => {
expect(isDiffStats({ added: 10, removed: 5, extra: "value" })).toBe(true)
})
})
})

View file

@ -1,55 +1,2 @@
import type { ClineMessage } from "@roo-code/types"
import { isDiffStats } from "./typeGuards"
/**
* Extract line statistics (added/removed) from tool approval messages in the message history.
* This function scans messages for diff statistics from completed tool approval requests,
* including both single file operations and batch operations.
*
* @param messages - Array of ClineMessage objects to analyze
* @returns Object containing total lines added, removed, and whether any stats were found
*/
export 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 }
}
// This file was emptied as part of removing line change processing from backend
// The getLineStatsFromToolApprovalMessages() function has been removed

View file

@ -1,5 +1,3 @@
import type { DiffStats } from "@roo-code/types"
/**
* Type guard to check if a value is a finite number
* @param value - The value to check
@ -8,15 +6,3 @@ import type { DiffStats } from "@roo-code/types"
export function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value)
}
/**
* Type guard to check if a value conforms to the DiffStats interface
* @param value - The value to check
* @returns true if the value has valid `added` and `removed` properties that are finite numbers
*/
export 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)
}

View file

@ -160,50 +160,6 @@ 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({
@ -383,20 +339,6 @@ 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()}>
@ -572,28 +514,6 @@ 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

@ -116,34 +116,6 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
const displayCost = todo.cost ?? subtaskById?.cost
const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number"
const todoAddedIsFinite = typeof todo.added === "number" && Number.isFinite(todo.added)
const todoRemovedIsFinite = typeof todo.removed === "number" && Number.isFinite(todo.removed)
const displayAdded = todoAddedIsFinite ? todo.added : subtaskById?.added
const displayRemoved = todoRemovedIsFinite ? todo.removed : subtaskById?.removed
const displayAddedIsFinite = typeof displayAdded === "number" && Number.isFinite(displayAdded)
const displayRemovedIsFinite =
typeof displayRemoved === "number" && Number.isFinite(displayRemoved)
const hasValidSubtaskLink = typeof todo.subtaskId === "string" && todo.subtaskId.length > 0
// Upstream aggregation may coerce missing stats to 0.
// To avoid showing misleading `+0/0` for in-progress/pending rows,
// only render 0 while running if it was explicitly provided on the todo itself.
const canRenderAdded =
displayAddedIsFinite &&
(todoStatus === "completed" || displayAdded !== 0 || todoAddedIsFinite)
const canRenderRemoved =
displayRemovedIsFinite &&
(todoStatus === "completed" || displayRemoved !== 0 || todoRemovedIsFinite)
const shouldShowLineChanges = hasValidSubtaskLink && (canRenderAdded || canRenderRemoved)
const isAddedPositive = canRenderAdded && (displayAdded as number) > 0
const isRemovedPositive = canRenderRemoved && (displayRemoved as number) > 0
const isAddedZero = canRenderAdded && displayAdded === 0
const isRemovedZero = canRenderRemoved && displayRemoved === 0
return (
<li
key={todo.id || todo.content}
@ -162,38 +134,14 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
{todo.content}
</span>
{/* Token count and cost display */}
{(shouldShowCost || shouldShowLineChanges) && (
{shouldShowCost && (
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
{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",
isAddedPositive ? "font-medium text-vscode-charts-green" : "",
isAddedZero ? "opacity-50" : "",
)}>
{canRenderAdded ? `+${displayAdded}` : "\u00A0"}
</span>
<span
className={cn(
" text-right",
isRemovedPositive ? "font-medium text-vscode-charts-red" : "",
isRemovedZero ? "opacity-50" : "",
)}>
{canRenderRemoved ? `${displayRemoved}` : "\u00A0"}
</span>
</span>
)}
<span className="tabular-nums opacity-70">
{formatLargeNumber(displayTokens)}
</span>
<span className="tabular-nums min-w-[45px] text-right">
${displayCost.toFixed(2)}
</span>
</span>
)}
</li>

View file

@ -34,8 +34,6 @@ describe("TodoListDisplay", () => {
name: "Task 1: Change background colour",
tokens: 95400,
cost: 0.22,
added: 10,
removed: 4,
status: "completed",
hasNestedChildren: false,
},
@ -44,8 +42,6 @@ describe("TodoListDisplay", () => {
name: "Task 2: Add timestamp to bottom",
tokens: 95000,
cost: 0.24,
added: 3,
removed: 2,
status: "completed",
hasNestedChildren: false,
},
@ -183,207 +179,6 @@ 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("shows +0/0 for completed subtask when fallback metrics are explicitly zero", () => {
const todosMissingDirectLineChanges = [
{
id: "1",
content: "Task 1: Zero changes",
status: "completed",
subtaskId: "subtask-1",
},
]
const subtaskDetailsWithZeroLineChanges: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Task 1: Zero changes",
tokens: 1,
cost: 0.01,
added: 0,
removed: 0,
status: "completed",
hasNestedChildren: false,
},
]
render(
<TodoListDisplay
todos={todosMissingDirectLineChanges}
subtaskDetails={subtaskDetailsWithZeroLineChanges}
/>,
)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
const addedEl = screen.getByText("+0")
const removedEl = screen.getByText("0")
expect(addedEl).toBeInTheDocument()
expect(removedEl).toBeInTheDocument()
// Zero values should be visually muted (not green/red emphasized)
expect(addedEl.className).toContain("opacity-50")
expect(addedEl.className).not.toContain("text-vscode-charts-green")
expect(removedEl.className).toContain("opacity-50")
expect(removedEl.className).not.toContain("text-vscode-charts-red")
})
it("in-progress: does not show +0/0 when zeros only come from fallback", () => {
const todosMissingDirectLineChanges = [
{
id: "1",
content: "Task 1: Zero changes (running)",
status: "in_progress",
subtaskId: "subtask-1",
},
]
const subtaskDetailsWithZeroLineChanges: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Task 1: Zero changes (running)",
tokens: 1,
cost: 0.01,
added: 0,
removed: 0,
status: "active",
hasNestedChildren: false,
},
]
render(
<TodoListDisplay
todos={todosMissingDirectLineChanges}
subtaskDetails={subtaskDetailsWithZeroLineChanges}
/>,
)
// Expand
const header = screen.getByText("Task 1: Zero changes (running)")
fireEvent.click(header)
expect(screen.queryByText("+0")).not.toBeInTheDocument()
expect(screen.queryByText("0")).not.toBeInTheDocument()
})
it("in-progress: shows +0/0 when explicitly present on todo", () => {
const todosWithDirectLineChanges = [
{
id: "1",
content: "Task 1: Zero changes (explicit)",
status: "in_progress",
subtaskId: "subtask-1",
added: 0,
removed: 0,
},
]
render(<TodoListDisplay todos={todosWithDirectLineChanges} subtaskDetails={subtaskDetails} />)
// Expand
const header = screen.getByText("Task 1: Zero changes (explicit)")
fireEvent.click(header)
const addedEl = screen.getByText("+0")
const removedEl = screen.getByText("0")
expect(addedEl).toBeInTheDocument()
expect(removedEl).toBeInTheDocument()
expect(addedEl.className).toContain("opacity-50")
expect(removedEl.className).toContain("opacity-50")
})
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

@ -5,10 +5,6 @@ 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"