From 5f6d27982ce26ec8f9db1f1d9991e67ccf1bf964 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 13 Jun 2026 11:10:40 -0700 Subject: [PATCH] refactor(thread): share compact token formatting - Extract a shared token formatter for goal, context, compaction, and sub-agent UI - Update token labels to use consistent K/M suffixes across thread surfaces - Refresh tests for the new formatting --- .../thread/ChatPane/ChatPane.test.tsx | 2 +- .../parts/items/ActiveSubAgentTile.tsx | 8 ++--- .../parts/items/ContextCompaction.tsx | 11 ++++--- .../ChatPane/parts/items/SubAgentToolCall.tsx | 8 ++--- .../parts/items/WorkflowOverlayBody.tsx | 12 +++----- .../parts/items/subAgentProgressMeta.test.ts | 2 +- .../parts/items/subAgentProgressMeta.tsx | 9 ++---- .../components/thread/ThreadGoalDock.test.tsx | 4 +-- .../components/thread/ThreadGoalDock.tsx | 6 +--- .../components/thread/formatTokenCount.ts | 29 +++++++++++++++++++ .../thread/threadContextUsage.test.ts | 10 +++---- .../components/thread/threadContextUsage.ts | 16 +--------- 12 files changed, 55 insertions(+), 62 deletions(-) create mode 100644 src/renderer/components/thread/formatTokenCount.ts diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index e32a187c..7286d5ad 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -614,7 +614,7 @@ describe("ChatPane", () => { await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id)); expect(await screen.findByText("Opus")).toBeInTheDocument(); - expect(screen.getByText("336k tok")).toBeInTheDocument(); + expect(screen.getByText("336K tok")).toBeInTheDocument(); expect(screen.getByText("Bash")).toBeInTheDocument(); expect(screen.getByText("21 steps")).toBeInTheDocument(); expect(document.body).not.toHaveTextContent("gpt-parent-main"); diff --git a/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx b/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx index 7d0825ad..1a992caf 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx @@ -13,6 +13,7 @@ import { getRuntimeItemPayload } from "@/renderer/state/slices/runtimeEventSlice import type { ProjectLocation, ToolCallPayload, WorkflowRun } from "@/shared/contracts"; import { deriveToolDisplay, isWorkflowTool } from "./toolDisplay"; import { PixelLoader } from "@/renderer/components/common/PixelLoader"; +import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { ThreadDockHeader, ThreadDockList, ThreadDockSection } from "../../../ThreadDockUI"; import { parseWorkflowInfo } from "./workflowDisplay"; import { SubAgentProgressMeta, hasSubAgentProgressMeta } from "./subAgentProgressMeta"; @@ -211,7 +212,7 @@ function WorkflowDockStats({ run }: { run: WorkflowRun }) { const completed = countDoneWorkflowAgents(run); const parts: string[] = []; if (run.agentCount > 0) parts.push(`${completed}/${run.agentCount}`); - if (run.totalTokens !== undefined) parts.push(`${formatDockTokens(run.totalTokens)} tok`); + if (run.totalTokens !== undefined) parts.push(`${formatTokenCount(run.totalTokens)} tok`); if (run.durationMs !== undefined) parts.push(formatDockDuration(run.durationMs)); return ( @@ -241,11 +242,6 @@ function countDoneWorkflowAgents(run: WorkflowRun): number { return total; } -function formatDockTokens(n: number): string { - if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; - return String(n); -} - function formatDockDuration(ms: number): string { const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); diff --git a/src/renderer/components/thread/ChatPane/parts/items/ContextCompaction.tsx b/src/renderer/components/thread/ChatPane/parts/items/ContextCompaction.tsx index 8b75cc8c..62c50117 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ContextCompaction.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ContextCompaction.tsx @@ -3,6 +3,7 @@ import { Surface } from "@heroui/react"; import { Layers } from "lucide-react"; import type { ToolCallPayload } from "@/shared/contracts"; import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice"; +import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { chatMessageSurfaceClass } from "./chatMessageSurface"; interface ContextCompactionProps { @@ -49,8 +50,8 @@ interface CompactMetadata { function formatCompactionSummary(payload: unknown): string | null { const meta = readCompactMetadata(payload); if (!meta) return null; - const before = formatTokenCount(meta.pre_tokens); - const after = formatTokenCount(meta.post_tokens); + const before = formatTokenLabel(meta.pre_tokens); + const after = formatTokenLabel(meta.post_tokens); const trigger = meta.trigger === "manual" ? "manually compacted" : "compacted"; if (before && after) return `Context ${trigger}: ${before} → ${after} tokens`; if (before) return `Context ${trigger} from ${before} tokens`; @@ -64,11 +65,9 @@ function readCompactMetadata(payload: unknown): CompactMetadata | null { return args as CompactMetadata; } -function formatTokenCount(value: number | undefined): string | null { +function formatTokenLabel(value: number | undefined): string | null { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null; - if (value < 1000) return String(value); - if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`; - return `${(value / 1_000_000).toFixed(1)}M`; + return formatTokenCount(value); } /** diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx index 346e6243..ddc25f4b 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx @@ -9,6 +9,7 @@ import { type RuntimeChatItem, } from "@/renderer/state/slices/runtimeEventSlice"; import { useWorkflowRun } from "@/renderer/state/useWorkflowRun"; +import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { useChatPaneActions } from "../../chatPaneActionsContext"; import { getChildItemIdsStoreSelector } from "../../chatPaneSelectors"; import { extractAcpResultPart } from "./acpToolPayload"; @@ -277,7 +278,7 @@ function WorkflowRunStats({ run }: { run: WorkflowRun }) { parts.push(formatWorkflowDuration(run.durationMs)); } if (run.totalTokens !== undefined && run.totalTokens > 0) { - parts.push(`${formatWorkflowTokens(run.totalTokens)} tok`); + parts.push(`${formatTokenCount(run.totalTokens)} tok`); } if (run.totalToolCalls !== undefined && run.totalToolCalls > 0) { parts.push(`${run.totalToolCalls} tools`); @@ -315,11 +316,6 @@ function sumTrackedAgents(run: WorkflowRun): number { return total; } -function formatWorkflowTokens(n: number): string { - if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; - return String(n); -} - function formatWorkflowDuration(ms: number): string { const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); diff --git a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx index 8e5439dd..4bcfe865 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx @@ -10,6 +10,7 @@ import type { } from "@/shared/contracts"; import { LightballTabs, PixelLoader, type LightballTab } from "@/renderer/components/common"; import { ThreadDockRow } from "@/renderer/components/thread/ThreadDockUI"; +import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { useWorkflowRun } from "@/renderer/state/useWorkflowRun"; import type { WorkflowInfo } from "./workflowDisplay"; @@ -129,7 +130,7 @@ function WorkflowToolbar({ const statParts: string[] = []; if (total > 0) statParts.push(`${completed}/${total} agents`); if (duration !== undefined) statParts.push(formatDuration(duration)); - if (tokens !== undefined) statParts.push(`${formatTokens(tokens)} tok`); + if (tokens !== undefined) statParts.push(`${formatTokenCount(tokens)} tok`); if (tools !== undefined) statParts.push(`${tools} tools`); const hasStats = statParts.length > 0 || status !== "unknown"; @@ -237,7 +238,7 @@ function AgentRow({ const labelDisplay = stripPhasePrefix(agent.label, phaseTitle); const stats: string[] = []; if (agent.model) stats.push(formatModel(agent.model)); - if (agent.tokens !== undefined) stats.push(`${formatTokens(agent.tokens)} tok`); + if (agent.tokens !== undefined) stats.push(`${formatTokenCount(agent.tokens)} tok`); if (agent.toolCalls !== undefined) stats.push(`${agent.toolCalls} tools`); if (done && agent.durationMs !== undefined) stats.push(formatDuration(agent.durationMs)); return ( @@ -283,7 +284,7 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase

{[ agent.model, - agent.tokens !== undefined ? `${formatTokens(agent.tokens)} tok` : null, + agent.tokens !== undefined ? `${formatTokenCount(agent.tokens)} tok` : null, agent.toolCalls !== undefined ? `${agent.toolCalls} tool calls` : null, agent.durationMs !== undefined ? formatDuration(agent.durationMs) : null, ] @@ -495,11 +496,6 @@ function stripPhasePrefix(label: string, phaseTitle: string): string { return label; } -function formatTokens(n: number): string { - if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; - return String(n); -} - function formatDuration(ms: number): string { const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); diff --git a/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.test.ts b/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.test.ts index 74333868..a35a83a3 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.test.ts @@ -16,7 +16,7 @@ describe("subAgentProgressMeta", () => { }), ).toEqual([ { kind: "model", label: "Opus" }, - { kind: "tokens", label: "336k tok" }, + { kind: "tokens", label: "336K tok" }, { kind: "live", label: "Bash" }, { kind: "steps", label: "21 steps" }, ]); diff --git a/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.tsx b/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.tsx index eeae8d1f..8ba10068 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/subAgentProgressMeta.tsx @@ -1,6 +1,7 @@ import { Fragment } from "react"; import type { ToolCallProgress } from "@/shared/contracts"; import { PixelLoader } from "@/renderer/components/common/PixelLoader"; +import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { formatBracketParamHints, stripBracketParams, @@ -88,19 +89,13 @@ export function formatSubAgentModelLabel(model: string | undefined): string | un function formatSubAgentTokenLabel(tokens: number | undefined): string | undefined { if (tokens === undefined || tokens <= 0) return undefined; - return `${formatCompactNumber(tokens)} tok`; + return `${formatTokenCount(tokens)} tok`; } function formatSubAgentStepLabel(stepCount: number): string { return `${stepCount} step${stepCount === 1 ? "" : "s"}`; } -function formatCompactNumber(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - return String(n); -} - function formatKnownModelId(modelId: string): string | undefined { const claudeShort = /^(opus|sonnet|haiku)$/i.exec(modelId); if (claudeShort) return capitalizeSegment(claudeShort[1]!); diff --git a/src/renderer/components/thread/ThreadGoalDock.test.tsx b/src/renderer/components/thread/ThreadGoalDock.test.tsx index 1a45a3d3..838fa694 100644 --- a/src/renderer/components/thread/ThreadGoalDock.test.tsx +++ b/src/renderer/components/thread/ThreadGoalDock.test.tsx @@ -35,7 +35,7 @@ describe("ThreadGoalDock", () => { expect(screen.getByLabelText("Thread goal dock")).toHaveAttribute("data-placement", "composer"); expect(screen.getByText("Goal")).toBeInTheDocument(); expect(screen.getByText("Ship goal dock")).toBeInTheDocument(); - expect(screen.getByText("120/1000 tokens")).toBeInTheDocument(); + expect(screen.getByText("120/1K tokens")).toBeInTheDocument(); expect(screen.getByText("5s")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Close goal" })); @@ -65,7 +65,7 @@ describe("ThreadGoalDock", () => { , ); - expect(screen.getByText("Complete · 11k tokens")).toBeInTheDocument(); + expect(screen.getByText("Complete · 11K tokens")).toBeInTheDocument(); expect(screen.getByText("10m 21s")).toBeInTheDocument(); }); diff --git a/src/renderer/components/thread/ThreadGoalDock.tsx b/src/renderer/components/thread/ThreadGoalDock.tsx index 0dbf5ec1..3f9778d1 100644 --- a/src/renderer/components/thread/ThreadGoalDock.tsx +++ b/src/renderer/components/thread/ThreadGoalDock.tsx @@ -4,6 +4,7 @@ import { CircleCheckBig, Target, X } from "lucide-react"; import type { ThreadGoalDockState } from "./threadGoalState"; import { ThreadDockSection } from "./ThreadDockUI"; import { formatElapsed } from "./ChatPane/formatElapsed"; +import { formatTokenCount } from "./formatTokenCount"; interface ThreadGoalDockProps { state: ThreadGoalDockState; @@ -140,11 +141,6 @@ function goalMeta(state: ThreadGoalDockState): string[] { return details; } -function formatTokenCount(tokens: number): string { - if (tokens < 10_000) return String(tokens); - return `${Math.floor(tokens / 1_000)}k`; -} - function goalStatusLabel(status: ThreadGoalDockState["status"]): string { switch (status) { case "active": diff --git a/src/renderer/components/thread/formatTokenCount.ts b/src/renderer/components/thread/formatTokenCount.ts new file mode 100644 index 00000000..7703f3c7 --- /dev/null +++ b/src/renderer/components/thread/formatTokenCount.ts @@ -0,0 +1,29 @@ +/** + * Compact, human-readable token count shared by every thread surface that shows + * a token total — the goal dock, context-usage indicator, context-compaction + * tile, and the sub-agent tiles. Keeping one formatter means a 24.7M-token + * thread reads the same everywhere ("24.8M") instead of a long "24767k". + * + * formatTokenCount(595) -> "595" + * formatTokenCount(8_400) -> "8.4K" + * formatTokenCount(200_000) -> "200K" + * formatTokenCount(1_000_000) -> "1M" + * formatTokenCount(24_767_000) -> "24.8M" + */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return formatTokenUnit(tokens / 1_000_000, "M"); + } + if (tokens >= 1_000) { + return formatTokenUnit(tokens / 1_000, "K"); + } + return String(tokens); +} + +function formatTokenUnit(value: number, unit: "K" | "M"): string { + // Millions always keep one decimal of precision (e.g. "24.8M"), as do small + // thousands (e.g. "8.4K"); larger thousands round to a whole number once the + // decimal is just noise (e.g. "200K"). Whole values drop the trailing ".0". + const rounded = unit === "M" || value < 10 ? Math.round(value * 10) / 10 : Math.round(value); + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}${unit}`; +} diff --git a/src/renderer/components/thread/threadContextUsage.test.ts b/src/renderer/components/thread/threadContextUsage.test.ts index 8b1d42b2..540ff5d2 100644 --- a/src/renderer/components/thread/threadContextUsage.test.ts +++ b/src/renderer/components/thread/threadContextUsage.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentStatus, Thread } from "@/shared/contracts"; -import { - formatTokenCount, - hasReportedContextUsage, - resolveThreadContextUsageSummary, -} from "./threadContextUsage"; +import { formatTokenCount } from "./formatTokenCount"; +import { hasReportedContextUsage, resolveThreadContextUsageSummary } from "./threadContextUsage"; const baseThread: Thread = { id: "thread-1", @@ -54,6 +51,9 @@ describe("threadContextUsage", () => { expect(formatTokenCount(8_400)).toBe("8.4K"); expect(formatTokenCount(200_000)).toBe("200K"); expect(formatTokenCount(1_000_000)).toBe("1M"); + expect(formatTokenCount(1_500_000)).toBe("1.5M"); + expect(formatTokenCount(24_767_000)).toBe("24.8M"); + expect(formatTokenCount(150_000_000)).toBe("150M"); }); it("combines provider usage with configured context limit", () => { diff --git a/src/renderer/components/thread/threadContextUsage.ts b/src/renderer/components/thread/threadContextUsage.ts index 21b0b85f..e55762ab 100644 --- a/src/renderer/components/thread/threadContextUsage.ts +++ b/src/renderer/components/thread/threadContextUsage.ts @@ -6,6 +6,7 @@ import type { ThreadContextUsage, } from "@/shared/contracts"; import { capabilitiesForPresentation } from "./threadComposerOptions"; +import { formatTokenCount } from "./formatTokenCount"; export interface ThreadContextUsageSummary { usedTokens?: number; @@ -87,21 +88,6 @@ export function resolveThreadContextUsageSummary(input: { }; } -export function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) { - return formatTokenUnit(tokens / 1_000_000, "M"); - } - if (tokens >= 1_000) { - return formatTokenUnit(tokens / 1_000, "K"); - } - return String(tokens); -} - -function formatTokenUnit(value: number, unit: "K" | "M"): string { - const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10; - return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}${unit}`; -} - function inferConfiguredContextLimit( thread: Thread, capabilities: AgentCapability | undefined,