diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx
index d5424b74221..801cd7d191d 100644
--- a/webview-ui/src/components/chat/TaskHeader.tsx
+++ b/webview-ui/src/components/chat/TaskHeader.tsx
@@ -33,6 +33,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import { LucideIconButton } from "./LucideIconButton"
+import { useDiffStats } from "./hooks/useDiffStats"
export interface TaskHeaderProps {
task: ClineMessage
@@ -69,6 +70,8 @@ const TaskHeader = ({
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
+ const diffStats = useDiffStats(clineMessages)
+ const hasDiffStats = diffStats.totalAdded > 0 || diffStats.totalRemoved > 0
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
@@ -335,6 +338,15 @@ const TaskHeader = ({
>
)}
+ {hasDiffStats && (
+ <>
+ ยท
+
+ +{diffStats.totalAdded}
+ -{diffStats.totalRemoved}
+
+ >
+ )}
{showBrowserGlobe && (
e.stopPropagation()}>
@@ -500,6 +512,27 @@ const TaskHeader = ({
)}
+ {/* Lines changed display */}
+ {hasDiffStats && (
+
+ |
+ {t("chat:task.linesChanged")}
+ |
+
+
+
+ +{diffStats.totalAdded}
+
+
+ -{diffStats.totalRemoved}
+
+
+ |
+
+ )}
+
{/* Size display */}
{!!currentTaskItem?.size && currentTaskItem.size > 0 && (
diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx
index c4ebe06973a..6099f949820 100644
--- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx
@@ -470,4 +470,119 @@ describe("TaskHeader", () => {
expect(screen.getByText("0%")).toBeInTheDocument()
})
})
+
+ describe("Diff stats display", () => {
+ beforeEach(() => {
+ mockExtensionState = {
+ apiConfiguration: {
+ apiProvider: "anthropic",
+ apiKey: "test-api-key",
+ apiModelId: "claude-3-opus-20240229",
+ } as ProviderSettings,
+ currentTaskItem: { id: "test-task-id" },
+ clineMessages: [],
+ }
+ mockModelInfo = undefined
+ mockMaxOutputTokens = 0
+ })
+
+ it("should not show diff stats when there are no file edits", () => {
+ mockExtensionState.clineMessages = [{ type: "say", say: "text", ts: Date.now(), text: "hello" }]
+ renderTaskHeader()
+ expect(screen.queryByTestId("compact-diff-stats")).not.toBeInTheDocument()
+ })
+
+ it("should show compact diff stats in collapsed view when file edits exist", () => {
+ mockExtensionState.clineMessages = [
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({
+ tool: "editedExistingFile",
+ path: "test.ts",
+ diffStats: { added: 10, removed: 3 },
+ }),
+ },
+ ]
+ renderTaskHeader()
+ const compactStats = screen.getByTestId("compact-diff-stats")
+ expect(compactStats).toBeInTheDocument()
+ expect(compactStats).toHaveTextContent("+10")
+ expect(compactStats).toHaveTextContent("-3")
+ })
+
+ it("should show lines changed row in expanded view when file edits exist", () => {
+ mockExtensionState.clineMessages = [
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({
+ tool: "editedExistingFile",
+ path: "test.ts",
+ diffStats: { added: 25, removed: 8 },
+ }),
+ },
+ ]
+ renderTaskHeader()
+
+ // Expand the task header
+ const taskText = screen.getByText("Test task")
+ fireEvent.click(taskText)
+
+ // Check for the lines changed label and values
+ expect(screen.getByTestId("lines-changed-label")).toBeInTheDocument()
+ expect(screen.getByTestId("lines-changed-label")).toHaveTextContent("chat:task.linesChanged")
+ const value = screen.getByTestId("lines-changed-value")
+ expect(value).toHaveTextContent("+25")
+ expect(value).toHaveTextContent("-8")
+ })
+
+ it("should aggregate diff stats across multiple tool messages", () => {
+ mockExtensionState.clineMessages = [
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({
+ tool: "editedExistingFile",
+ path: "a.ts",
+ diffStats: { added: 10, removed: 2 },
+ }),
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({
+ tool: "newFileCreated",
+ path: "b.ts",
+ diffStats: { added: 30, removed: 0 },
+ }),
+ },
+ ]
+ renderTaskHeader()
+ const compactStats = screen.getByTestId("compact-diff-stats")
+ expect(compactStats).toHaveTextContent("+40")
+ expect(compactStats).toHaveTextContent("-2")
+ })
+
+ it("should not show diff stats when all diffStats are zero", () => {
+ mockExtensionState.clineMessages = [
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({
+ tool: "editedExistingFile",
+ path: "test.ts",
+ diffStats: { added: 0, removed: 0 },
+ }),
+ },
+ ]
+ renderTaskHeader()
+ expect(screen.queryByTestId("compact-diff-stats")).not.toBeInTheDocument()
+ })
+ })
})
diff --git a/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts b/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts
new file mode 100644
index 00000000000..a8b6a2831a8
--- /dev/null
+++ b/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts
@@ -0,0 +1,128 @@
+// npx vitest src/components/chat/hooks/__tests__/useDiffStats.spec.ts
+
+import type { ClineMessage, ClineSayTool } from "@roo-code/types"
+
+import { aggregateDiffStats } from "../useDiffStats"
+
+/**
+ * Helper to build a ClineMessage that mimics an ask="tool" message carrying
+ * a serialised ClineSayTool payload.
+ */
+function toolMessage(tool: Partial, type: "ask" | "say" = "ask"): ClineMessage {
+ const base: ClineMessage = {
+ ts: Date.now(),
+ type,
+ text: JSON.stringify(tool),
+ } as any
+
+ if (type === "ask") {
+ ;(base as any).ask = "tool"
+ } else {
+ ;(base as any).say = "tool"
+ }
+
+ return base
+}
+
+describe("aggregateDiffStats", () => {
+ it("returns zeros for undefined messages", () => {
+ expect(aggregateDiffStats(undefined)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("returns zeros for empty messages array", () => {
+ expect(aggregateDiffStats([])).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("returns zeros when no tool messages exist", () => {
+ const messages: ClineMessage[] = [{ ts: Date.now(), type: "say", say: "text", text: "hello" } as any]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("aggregates diffStats from ask=tool editedExistingFile messages", () => {
+ const messages = [
+ toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 10, removed: 3 } }),
+ toolMessage({ tool: "editedExistingFile", path: "b.ts", diffStats: { added: 5, removed: 2 } }),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 15, totalRemoved: 5 })
+ })
+
+ it("aggregates diffStats from say=tool messages", () => {
+ const messages = [
+ toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 7, removed: 1 } }, "say"),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 7, totalRemoved: 1 })
+ })
+
+ it("aggregates diffStats from appliedDiff tool", () => {
+ const messages = [toolMessage({ tool: "appliedDiff", path: "c.ts", diffStats: { added: 20, removed: 10 } })]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 20, totalRemoved: 10 })
+ })
+
+ it("aggregates diffStats from newFileCreated tool", () => {
+ const messages = [toolMessage({ tool: "newFileCreated", path: "d.ts", diffStats: { added: 50, removed: 0 } })]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 50, totalRemoved: 0 })
+ })
+
+ it("ignores non-file-edit tools (readFile, searchFiles, etc.)", () => {
+ const messages = [
+ toolMessage({ tool: "readFile", path: "e.ts", diffStats: { added: 100, removed: 100 } } as any),
+ toolMessage({ tool: "searchFiles" } as any),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("handles messages with missing diffStats gracefully", () => {
+ const messages = [
+ toolMessage({ tool: "editedExistingFile", path: "f.ts" }), // no diffStats
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("aggregates per-file stats from batchDiffs", () => {
+ const messages = [
+ toolMessage({
+ tool: "appliedDiff",
+ batchDiffs: [
+ { path: "g.ts", changeCount: 2, key: "1", content: "...", diffStats: { added: 8, removed: 2 } },
+ { path: "h.ts", changeCount: 1, key: "2", content: "...", diffStats: { added: 3, removed: 1 } },
+ ],
+ }),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 11, totalRemoved: 3 })
+ })
+
+ it("aggregates both top-level and batchDiffs stats", () => {
+ const messages = [
+ toolMessage({
+ tool: "editedExistingFile",
+ path: "i.ts",
+ diffStats: { added: 5, removed: 2 },
+ }),
+ toolMessage({
+ tool: "appliedDiff",
+ batchDiffs: [
+ { path: "j.ts", changeCount: 1, key: "1", content: "...", diffStats: { added: 10, removed: 0 } },
+ ],
+ }),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 15, totalRemoved: 2 })
+ })
+
+ it("handles malformed JSON text gracefully", () => {
+ const messages: ClineMessage[] = [{ ts: Date.now(), type: "ask", ask: "tool", text: "not valid json" } as any]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("handles null text gracefully", () => {
+ const messages: ClineMessage[] = [{ ts: Date.now(), type: "ask", ask: "tool", text: null } as any]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
+ })
+
+ it("mixes ask and say tool messages", () => {
+ const messages = [
+ toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 3, removed: 1 } }, "ask"),
+ toolMessage({ tool: "newFileCreated", path: "b.ts", diffStats: { added: 20, removed: 0 } }, "say"),
+ ]
+ expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 23, totalRemoved: 1 })
+ })
+})
diff --git a/webview-ui/src/components/chat/hooks/useDiffStats.ts b/webview-ui/src/components/chat/hooks/useDiffStats.ts
new file mode 100644
index 00000000000..9acb063d091
--- /dev/null
+++ b/webview-ui/src/components/chat/hooks/useDiffStats.ts
@@ -0,0 +1,73 @@
+import { useMemo } from "react"
+
+import type { ClineMessage, ClineSayTool } from "@roo-code/types"
+
+import { safeJsonParse } from "@roo/core"
+
+/** Tools that produce file-editing diffs with diffStats. */
+const FILE_EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated"])
+
+export interface AggregatedDiffStats {
+ totalAdded: number
+ totalRemoved: number
+}
+
+/**
+ * Extracts and aggregates diffStats from all file-editing tool messages in the
+ * provided clineMessages array. Both `ask === "tool"` and `say === "tool"`
+ * messages are inspected because the backend emits diffStats in both paths.
+ *
+ * Handles:
+ * - Individual file tools with a top-level `diffStats` property.
+ * - Batch diff tools with per-file `batchDiffs[].diffStats` entries.
+ *
+ * The result is memoized so it only recomputes when the messages array identity
+ * or length changes.
+ */
+export function useDiffStats(clineMessages: ClineMessage[] | undefined): AggregatedDiffStats {
+ return useMemo(() => aggregateDiffStats(clineMessages), [clineMessages])
+}
+
+/**
+ * Pure function (no hooks) that performs the aggregation. Useful in tests and
+ * in non-React contexts.
+ */
+export function aggregateDiffStats(clineMessages: ClineMessage[] | undefined): AggregatedDiffStats {
+ let totalAdded = 0
+ let totalRemoved = 0
+
+ if (!clineMessages || clineMessages.length === 0) {
+ return { totalAdded, totalRemoved }
+ }
+
+ for (const msg of clineMessages) {
+ // Tool messages can appear as ask="tool" or say="tool"
+ const isTool = msg.ask === "tool" || (msg as any).say === "tool"
+ if (!isTool || !msg.text) {
+ continue
+ }
+
+ const parsed = safeJsonParse(msg.text)
+ if (!parsed) {
+ continue
+ }
+
+ // Individual file tool
+ if (FILE_EDIT_TOOLS.has(parsed.tool) && parsed.diffStats) {
+ totalAdded += parsed.diffStats.added || 0
+ totalRemoved += parsed.diffStats.removed || 0
+ }
+
+ // Batch diffs (e.g. appliedDiff with multiple files)
+ if (parsed.batchDiffs && Array.isArray(parsed.batchDiffs)) {
+ for (const entry of parsed.batchDiffs) {
+ if (entry.diffStats) {
+ totalAdded += entry.diffStats.added || 0
+ totalRemoved += entry.diffStats.removed || 0
+ }
+ }
+ }
+ }
+
+ return { totalAdded, totalRemoved }
+}
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index b9652cfce5c..217fc5d7228 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -29,7 +29,8 @@
"openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open.",
"openApiHistory": "Open API History",
"openUiHistory": "Open UI History",
- "backToParentTask": "Parent task"
+ "backToParentTask": "Parent task",
+ "linesChanged": "Lines Changed"
},
"unpin": "Unpin",
"pin": "Pin",