diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx new file mode 100644 index 000000000..bc6c6b085 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx @@ -0,0 +1,65 @@ +import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ContextBreakdownPopover } from "./ContextBreakdownPopover"; + +function usageWith( + breakdown: ContextUsage["breakdown"], + overrides?: Partial, +): ContextUsage { + return { + used: 74_000, + size: 200_000, + percentage: 37, + cost: null, + breakdown, + ...overrides, + }; +} + +describe("ContextBreakdownPopover", () => { + it("renders the header with aggregate tokens", () => { + render( + + + , + ); + expect(screen.getByText(/74K \/ 200K tokens/)).toBeInTheDocument(); + expect(screen.getByText("37% full")).toBeInTheDocument(); + }); + + it("shows the placeholder copy when breakdown is missing", () => { + render( + + + , + ); + expect( + screen.getByText(/Detailed breakdown available after the first response/), + ).toBeInTheDocument(); + }); + + it("renders one row per non-zero category", () => { + render( + + + , + ); + expect(screen.getByText("System prompt")).toBeInTheDocument(); + expect(screen.getByText("MCP")).toBeInTheDocument(); + expect(screen.getByText("Conversation")).toBeInTheDocument(); + expect(screen.queryByText("Tools")).not.toBeInTheDocument(); + expect(screen.queryByText("Rules")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx new file mode 100644 index 000000000..5ecbe90fd --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx @@ -0,0 +1,117 @@ +import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; +import { + CONTEXT_CATEGORIES, + formatTokensCompact, + getOverallUsageColor, +} from "@features/sessions/utils/contextColors"; +import { Flex, Text } from "@radix-ui/themes"; + +interface ContextBreakdownPopoverProps { + usage: ContextUsage; +} + +export function ContextBreakdownPopover({ + usage, +}: ContextBreakdownPopoverProps) { + const { used, size, percentage, breakdown } = usage; + const fillColor = getOverallUsageColor(percentage); + + return ( + + + + Context + + + ~{formatTokensCompact(used)} / {formatTokensCompact(size)} tokens + + + + + {percentage}% full + + + {breakdown ? ( + + ) : ( + + )} + + {breakdown ? ( + + {CONTEXT_CATEGORIES.filter((c) => breakdown[c.key] > 0).map((cat) => ( + + + + {cat.label} + + + {formatTokensCompact(breakdown[cat.key])} + + + ))} + + ) : ( + + Detailed breakdown available after the first response. + + )} + + ); +} + +function SegmentedBar({ + breakdown, + total, + fallback, +}: { + breakdown: NonNullable; + total: number; + fallback: string; +}) { + if (total <= 0) { + return
; + } + return ( +
+ {CONTEXT_CATEGORIES.map((cat) => { + const value = breakdown[cat.key]; + if (value <= 0) return null; + return ( +
+ ); + })} +
+ ); +} + +function SinglePercentBar({ + percentage, + color, +}: { + percentage: number; + color: string; +}) { + return ( +
+
+
+ ); +} diff --git a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx index 79b23ff68..f1ac3c11b 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx +++ b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx @@ -1,24 +1,10 @@ -import { Tooltip } from "@components/ui/Tooltip"; import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; -import { Flex, Text } from "@radix-ui/themes"; - -function formatTokensCompact(tokens: number): string { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } - return `${Math.round(tokens / 1000)}K`; -} - -function formatTokensFull(tokens: number): string { - return tokens.toLocaleString(); -} - -function getUsageColor(percentage: number): string { - if (percentage >= 90) return "var(--red-9)"; - if (percentage >= 75) return "var(--orange-9)"; - if (percentage >= 50) return "var(--amber-9)"; - return "var(--green-9)"; -} +import { + formatTokensCompact, + getOverallUsageColor, +} from "@features/sessions/utils/contextColors"; +import { Flex, Popover, Text } from "@radix-ui/themes"; +import { ContextBreakdownPopover } from "./ContextBreakdownPopover"; const CIRCLE_SIZE = 20; const STROKE_WIDTH = 2.5; @@ -34,45 +20,54 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { const { used, size, percentage } = usage; const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE; - const color = getUsageColor(percentage); + const color = getOverallUsageColor(percentage); return ( - - - + + - - {formatTokensCompact(used)}/{formatTokensCompact(size)} - - - + + + + {formatTokensCompact(used)}/{formatTokensCompact(size)} ยท{" "} + {percentage}% + + + + + + + + ); } diff --git a/apps/code/src/renderer/features/sessions/utils/contextColors.ts b/apps/code/src/renderer/features/sessions/utils/contextColors.ts new file mode 100644 index 000000000..befe85260 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/utils/contextColors.ts @@ -0,0 +1,33 @@ +import type { ContextBreakdown } from "@features/sessions/hooks/useContextUsage"; + +export interface CategoryStyle { + key: keyof ContextBreakdown; + label: string; + color: string; +} + +// Ordered like the design spec: System prompt, Tools, Rules, Skills, MCP, +// Subagents, Conversation. Colors reuse Radix scales so they read in both +// light/dark modes. +export const CONTEXT_CATEGORIES: readonly CategoryStyle[] = [ + { key: "systemPrompt", label: "System prompt", color: "var(--gray-9)" }, + { key: "tools", label: "Tools", color: "var(--violet-9)" }, + { key: "rules", label: "Rules", color: "var(--green-9)" }, + { key: "skills", label: "Skills", color: "var(--amber-9)" }, + { key: "mcp", label: "MCP", color: "var(--pink-9)" }, + { key: "subagents", label: "Subagents", color: "var(--blue-9)" }, + { key: "conversation", label: "Conversation", color: "var(--orange-9)" }, +] as const; + +export function getOverallUsageColor(percentage: number): string { + if (percentage >= 90) return "var(--red-9)"; + if (percentage >= 75) return "var(--orange-9)"; + if (percentage >= 50) return "var(--amber-9)"; + return "var(--green-9)"; +} + +export function formatTokensCompact(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; + return tokens.toString(); +}