feat: add token consumption tracking per task

- Add TokenUsage interface to track input/output tokens and cost
- Capture usage from Claude SDK result messages in auto-mode-service
- Accumulate token usage across multiple streams and follow-up sessions
- Store token usage in feature.json for persistence
- Display token count and cost on Kanban cards with tooltip breakdown
- Add token usage summary header in log viewer

Closes #166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 03:05:13 +01:00
parent d104a24446
commit 3559e0104c
7 changed files with 218 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ import {
Circle,
Play,
Loader2,
Coins,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -34,10 +35,35 @@ import {
type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser";
import type { TokenUsage } from "@/store/app-store";
interface LogViewerProps {
output: string;
className?: string;
tokenUsage?: TokenUsage;
}
/**
* Formats token counts for compact display (e.g., 12500 -> "12.5K")
*/
function formatTokenCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
}
/**
* Formats cost for display (e.g., 0.0847 -> "$0.0847")
*/
function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
const getLogIcon = (type: LogEntryType) => {
@@ -413,7 +439,7 @@ interface ToolCategoryStats {
other: number;
}
export function LogViewer({ output, className }: LogViewerProps) {
export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
@@ -615,6 +641,40 @@ export function LogViewer({ output, className }: LogViewerProps) {
return (
<div className={cn("flex flex-col", className)}>
{/* Token Usage Summary Header */}
{tokenUsage && tokenUsage.totalTokens > 0 && (
<div className="mb-3 p-2 bg-zinc-900/50 rounded-lg border border-zinc-700/50">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-amber-400" />
<span className="font-medium">{formatTokenCount(tokenUsage.totalTokens)}</span>
<span className="text-muted-foreground/60">tokens</span>
</span>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<span className="text-green-400">IN:</span>
<span>{formatTokenCount(tokenUsage.inputTokens)}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-400">OUT:</span>
<span>{formatTokenCount(tokenUsage.outputTokens)}</span>
</span>
{tokenUsage.cacheReadInputTokens > 0 && (
<>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<span className="text-purple-400">Cache:</span>
<span>{formatTokenCount(tokenUsage.cacheReadInputTokens)}</span>
</span>
</>
)}
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1 text-amber-400 font-medium">
{formatCost(tokenUsage.costUSD)}
</span>
</div>
</div>
)}
{/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">

View File

@@ -57,6 +57,7 @@ import {
Wand2,
Archive,
Lock,
Coins,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -90,6 +91,29 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
return labels[level];
}
/**
* Formats token counts for compact display (e.g., 12500 -> "12.5K")
*/
function formatTokenCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
}
/**
* Formats cost for display (e.g., 0.0847 -> "$0.0847")
*/
function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -873,6 +897,36 @@ export const KanbanCard = memo(function KanbanCard({
)}
</div>
)}
{/* Token Usage Display */}
{feature.tokenUsage && feature.tokenUsage.totalTokens > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
<Coins className="w-2.5 h-2.5 text-amber-400" />
{formatTokenCount(feature.tokenUsage.totalTokens)} tokens
<span className="text-amber-400/80">
({formatCost(feature.tokenUsage.costUSD)})
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="space-y-1">
<p>Input: {formatTokenCount(feature.tokenUsage.inputTokens)}</p>
<p>Output: {formatTokenCount(feature.tokenUsage.outputTokens)}</p>
{feature.tokenUsage.cacheReadInputTokens > 0 && (
<p>Cache read: {formatTokenCount(feature.tokenUsage.cacheReadInputTokens)}</p>
)}
<p className="font-medium pt-1 border-t border-border/30">
Cost: {formatCost(feature.tokenUsage.costUSD)}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</>
)}
</div>

View File

@@ -44,6 +44,8 @@ export function AgentOutputModal({
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
const features = useAppStore((state) => state.features);
const feature = features.find((f) => f.id === featureId);
// Auto-scroll to bottom when output changes
useEffect(() => {
@@ -387,7 +389,7 @@ export function AgentOutputModal({
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
<LogViewer output={output} tokenUsage={feature?.tokenUsage} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}

View File

@@ -278,6 +278,16 @@ export interface AIProfile {
icon?: string; // Optional icon name from lucide
}
// Token usage statistics for tracking API costs per task
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalTokens: number;
costUSD: number;
}
export interface Feature {
id: string;
category: string;
@@ -305,6 +315,7 @@ export interface Feature {
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
tokenUsage?: TokenUsage; // Token consumption tracking for this task
}
// Parsed task from spec (for spec and full planning modes)