diff --git a/apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx b/apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx index 40dad9ea0..e9221fb6c 100644 --- a/apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx +++ b/apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx @@ -1,6 +1,7 @@ "use client"; import type { AskUserQuestionInput } from "@open-harness/agent"; +import { formatTokens } from "@open-harness/shared"; import { isReasoningUIPart, isToolUIPart, @@ -394,13 +395,6 @@ function isSandboxValid(sandboxInfo: SandboxInfo | null): boolean { return Date.now() < expiresAt; } -function formatTokens(tokens: number): string { - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}k`; - } - return tokens.toString(); -} - function formatUsd(amount: number): string { if (amount >= 100) { return "$" + amount.toLocaleString("en-US", { maximumFractionDigits: 0 }); diff --git a/apps/web/app/settings/leaderboard-section.tsx b/apps/web/app/settings/leaderboard-section.tsx index fb8dec025..874bfe44a 100644 --- a/apps/web/app/settings/leaderboard-section.tsx +++ b/apps/web/app/settings/leaderboard-section.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatTokens } from "@open-harness/shared"; import { useMemo, useState } from "react"; import useSWR from "swr"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -49,13 +50,6 @@ function buildUsagePath(range: LeaderboardRange): string { return `/api/usage?${query.toString()}`; } -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - function displayModelId(modelId: string | null): string { if (!modelId) { return "Unknown"; diff --git a/apps/web/app/settings/profile/page.tsx b/apps/web/app/settings/profile/page.tsx index 14841192e..a26361020 100644 --- a/apps/web/app/settings/profile/page.tsx +++ b/apps/web/app/settings/profile/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatTokens } from "@open-harness/shared"; import { useMemo, useState } from "react"; import Image from "next/image"; import useSWR from "swr"; @@ -65,13 +66,6 @@ interface CostEstimateSummary { // ── Helpers ──────────────────────────────────────────────────────────────── -function formatTokens(n: number) { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - function sumRows(rows: DailyUsageRow[]) { return rows.reduce( (acc, d) => ({ diff --git a/apps/web/app/settings/usage-section.tsx b/apps/web/app/settings/usage-section.tsx index 1fa88a153..7cb4f8cc5 100644 --- a/apps/web/app/settings/usage-section.tsx +++ b/apps/web/app/settings/usage-section.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatTokens } from "@open-harness/shared"; import { useMemo, useState } from "react"; import useSWR from "swr"; import type { DateRange } from "react-day-picker"; @@ -74,13 +75,6 @@ interface CostEstimateSummary { totalTokens: number; } -function formatTokens(n: number) { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - function formatDateRangeLabel(range: DateRange | undefined) { if (!range?.from) { return "Token consumption and activity over the past 39 weeks. Click the chart to filter."; diff --git a/apps/web/app/settings/usage/domain-usage-leaderboard-section.tsx b/apps/web/app/settings/usage/domain-usage-leaderboard-section.tsx index 09e02db9f..8731366c9 100644 --- a/apps/web/app/settings/usage/domain-usage-leaderboard-section.tsx +++ b/apps/web/app/settings/usage/domain-usage-leaderboard-section.tsx @@ -13,19 +13,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { formatTokens } from "@open-harness/shared"; import type { UsageDomainLeaderboard } from "@/lib/usage/types"; interface DomainUsageLeaderboardSectionProps { leaderboard: UsageDomainLeaderboard; } -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - function displayModelId(modelId: string | null): string { if (!modelId) { return "Unknown"; diff --git a/apps/web/app/settings/usage/usage-insights-section.tsx b/apps/web/app/settings/usage/usage-insights-section.tsx index 1800a2b81..10658c671 100644 --- a/apps/web/app/settings/usage/usage-insights-section.tsx +++ b/apps/web/app/settings/usage/usage-insights-section.tsx @@ -1,16 +1,10 @@ +import { formatTokens } from "@open-harness/shared"; import type { UsageInsights } from "@/lib/usage/types"; interface UsageInsightsSectionProps { insights: UsageInsights; } -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - function formatPercent(ratio: number): string { return `${Math.round(ratio * 100)}%`; } diff --git a/apps/web/components/contribution-chart.tsx b/apps/web/components/contribution-chart.tsx index 21a1dda84..68f598c52 100644 --- a/apps/web/components/contribution-chart.tsx +++ b/apps/web/components/contribution-chart.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatTokens } from "@open-harness/shared"; import { useMemo } from "react"; import type { DateRange } from "react-day-picker"; import { @@ -71,12 +72,6 @@ function formatDate(dateStr: string) { }); } -function formatTokens(n: number) { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - function formatDateKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } diff --git a/packages/shared/lib/tool-state.test.ts b/packages/shared/lib/tool-state.test.ts new file mode 100644 index 000000000..222e238b7 --- /dev/null +++ b/packages/shared/lib/tool-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { formatTokens } from "./tool-state"; + +describe("formatTokens", () => { + test("returns raw number for values under 1,000", () => { + expect(formatTokens(0)).toBe("0"); + expect(formatTokens(1)).toBe("1"); + expect(formatTokens(999)).toBe("999"); + }); + + test("formats thousands with k suffix", () => { + expect(formatTokens(1_000)).toBe("1.0k"); + expect(formatTokens(1_200)).toBe("1.2k"); + expect(formatTokens(15_800)).toBe("15.8k"); + expect(formatTokens(500_000)).toBe("500.0k"); + }); + + test("formats millions with m suffix", () => { + expect(formatTokens(1_000_000)).toBe("1.0m"); + expect(formatTokens(1_005_000)).toBe("1.0m"); + expect(formatTokens(2_500_000)).toBe("2.5m"); + expect(formatTokens(150_000_000)).toBe("150.0m"); + }); + + test("formats billions with b suffix", () => { + expect(formatTokens(1_000_000_000)).toBe("1.0b"); + expect(formatTokens(2_500_000_000)).toBe("2.5b"); + expect(formatTokens(10_000_000_000)).toBe("10.0b"); + }); + + test("promotes to next tier at rounding boundary instead of showing 1000.0x", () => { + // 999,950 / 1000 = 999.95, which .toFixed(1) rounds to "1000.0" + // Should promote to "1.0m" instead of "1000.0k" + expect(formatTokens(999_950)).toBe("1.0m"); + expect(formatTokens(999_999)).toBe("1.0m"); + + // Same boundary for m → b + expect(formatTokens(999_950_000)).toBe("1.0b"); + expect(formatTokens(999_999_999)).toBe("1.0b"); + + // Values just below the rounding boundary stay in the lower tier + expect(formatTokens(999_949)).toBe("999.9k"); + expect(formatTokens(999_949_999)).toBe("999.9m"); + }); + + test("never produces values like 1000k or 1000m", () => { + const result1005k = formatTokens(1_005_000); + expect(result1005k).toBe("1.0m"); + expect(result1005k).not.toContain("1005"); + + const result1000k = formatTokens(1_000_000); + expect(result1000k).toBe("1.0m"); + expect(result1000k).not.toContain("1000k"); + + const result1000m = formatTokens(1_000_000_000); + expect(result1000m).toBe("1.0b"); + expect(result1000m).not.toContain("1000m"); + }); +}); diff --git a/packages/shared/lib/tool-state.ts b/packages/shared/lib/tool-state.ts index f195d4af0..e409ac643 100644 --- a/packages/shared/lib/tool-state.ts +++ b/packages/shared/lib/tool-state.ts @@ -131,11 +131,16 @@ export function toRelativePath(filePath: string, cwd: string): string { /** * Format token count for display. - * Shows "1.2k" for numbers >= 1000, otherwise raw number. + * Examples: 500 → "500", 1200 → "1.2k", 999950 → "1.0m", 2500000000 → "2.5b" + * + * Uses 999.95 as the promotion threshold so .toFixed(1) rounding never + * produces "1000.0k" or "1000.0m" — those values get bumped to the next unit. */ export function formatTokens(tokens: number): string { - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}k`; - } - return tokens.toString(); + if (tokens >= 999_950_000_000) + return `${(tokens / 1_000_000_000_000).toFixed(1)}t`; + if (tokens >= 999_950_000) return `${(tokens / 1_000_000_000).toFixed(1)}b`; + if (tokens >= 999_950) return `${(tokens / 1_000_000).toFixed(1)}m`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return tokens.toLocaleString(); }