Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span className="shrink-0 tabular-nums text-foreground-muted opacity-80">
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`;
Expand All @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -283,7 +284,7 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase
<p className="text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
{[
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,
]
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]!);
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/thread/ThreadGoalDock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down Expand Up @@ -65,7 +65,7 @@ describe("ThreadGoalDock", () => {
</AppProvider>,
);

expect(screen.getByText("Complete · 11k tokens")).toBeInTheDocument();
expect(screen.getByText("Complete · 11K tokens")).toBeInTheDocument();
expect(screen.getByText("10m 21s")).toBeInTheDocument();
});

Expand Down
6 changes: 1 addition & 5 deletions src/renderer/components/thread/ThreadGoalDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/components/thread/formatTokenCount.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
10 changes: 5 additions & 5 deletions src/renderer/components/thread/threadContextUsage.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
16 changes: 1 addition & 15 deletions src/renderer/components/thread/threadContextUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ThreadContextUsage,
} from "@/shared/contracts";
import { capabilitiesForPresentation } from "./threadComposerOptions";
import { formatTokenCount } from "./formatTokenCount";

export interface ThreadContextUsageSummary {
usedTokens?: number;
Expand Down Expand Up @@ -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,
Expand Down