mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
Merge branch 'main' of https://github.com/andrewyng/openworker into feature/memory
This commit is contained in:
@@ -31,10 +31,19 @@ import {
|
||||
type SurfaceVisibility,
|
||||
type WorkspaceCommandTrust,
|
||||
} from "./api";
|
||||
import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types";
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
Attachment,
|
||||
Item,
|
||||
SessionInfo,
|
||||
SessionUsage,
|
||||
TodoItem,
|
||||
WsEvent,
|
||||
} from "./types";
|
||||
import { isProjectScoped } from "./personaScope";
|
||||
import { baseName } from "./paths";
|
||||
import { itemsFromMessages } from "./itemsFromMessages";
|
||||
import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage";
|
||||
import { streamMode } from "./streamGate";
|
||||
import { InboxItemCard } from "./components/InboxItemCard";
|
||||
import { isTauri, platformOS, startWindowDrag } from "./tauri";
|
||||
@@ -156,6 +165,12 @@ export function App() {
|
||||
const [model, setModel] = useState("gpt-5.6-sol");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelLabels, setModelLabels] = useState<Record<string, string>>({});
|
||||
// {full model id → context window in tokens} from the curated matrix (verified only);
|
||||
// drives the composer usage chip's context-fill meter.
|
||||
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
|
||||
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
|
||||
// accumulated live from assistant_message events, reset with the transcript.
|
||||
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
|
||||
const [surfaces, setSurfaces] = useState<SurfaceVisibility>({ cowork: true, chat: false, code: false });
|
||||
const [mode, setMode] = useState("interactive");
|
||||
const [connected, setConnected] = useState(false);
|
||||
@@ -395,9 +410,12 @@ export function App() {
|
||||
setBranch(null);
|
||||
}
|
||||
try {
|
||||
setItems(itemsFromMessages(await getSessionMessages(last.session_id)));
|
||||
const messages = await getSessionMessages(last.session_id);
|
||||
setItems(itemsFromMessages(messages));
|
||||
setUsage(usageFromMessages(messages));
|
||||
} catch {
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
}
|
||||
setSessionId(last.session_id);
|
||||
setShowGate(false);
|
||||
@@ -486,6 +504,7 @@ export function App() {
|
||||
.then((s) => {
|
||||
setModels(s.models || []);
|
||||
setModelLabels(s.model_labels || {});
|
||||
setModelContextWindows(s.model_context_windows || {});
|
||||
setModelReady(s.model_ready);
|
||||
if (s.surfaces) setSurfaces(s.surfaces);
|
||||
})
|
||||
@@ -601,6 +620,7 @@ export function App() {
|
||||
setReasoningStream(reasoningRef.current + (d.text || ""));
|
||||
break;
|
||||
case "assistant_message": {
|
||||
if (d.usage) setUsage((u) => addTurnUsage(u, d.usage));
|
||||
// The event's reasoning is authoritative (covers background-delivered turns);
|
||||
// the local buffer is the fallback for older servers.
|
||||
const reasoning = d.reasoning || reasoningRef.current;
|
||||
@@ -899,6 +919,7 @@ export function App() {
|
||||
const target = forAgent || agent;
|
||||
setSurface("session"); // return to the conversation view if we were on a sub-view
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setRunning(false);
|
||||
@@ -975,8 +996,10 @@ export function App() {
|
||||
try {
|
||||
const messages = await getSessionMessages(id);
|
||||
setItems(itemsFromMessages(messages));
|
||||
setUsage(usageFromMessages(messages));
|
||||
} catch {
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
}
|
||||
};
|
||||
const switchAgent = async (name: string) => {
|
||||
@@ -989,6 +1012,7 @@ export function App() {
|
||||
|
||||
setAgent(name);
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setRunning(false);
|
||||
@@ -1017,9 +1041,12 @@ export function App() {
|
||||
else setShowGate(true);
|
||||
setSessionId(target.sessionId);
|
||||
try {
|
||||
setItems(itemsFromMessages(await getSessionMessages(target.sessionId)));
|
||||
const messages = await getSessionMessages(target.sessionId);
|
||||
setItems(itemsFromMessages(messages));
|
||||
setUsage(usageFromMessages(messages));
|
||||
} catch {
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1043,6 +1070,7 @@ export function App() {
|
||||
setShowGate(false);
|
||||
setGateCreate(false);
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setSessionId(newId());
|
||||
@@ -1055,6 +1083,7 @@ export function App() {
|
||||
const target = forAgent || agent;
|
||||
setSurface("session");
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setRunning(false);
|
||||
@@ -1079,6 +1108,7 @@ export function App() {
|
||||
// Archiving the open chat: leave it and start fresh (it moves to the Archived section).
|
||||
if (archived && id === sessionId) {
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setRunning(false);
|
||||
@@ -1091,6 +1121,7 @@ export function App() {
|
||||
refreshSessions();
|
||||
if (id === sessionId) {
|
||||
setItems([]);
|
||||
setUsage(emptyUsage());
|
||||
setStreaming("");
|
||||
setTodo([]);
|
||||
setRunning(false);
|
||||
@@ -1569,6 +1600,8 @@ export function App() {
|
||||
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
||||
prefill={composerPrefill}
|
||||
resetKey={sessionId}
|
||||
usage={usage}
|
||||
contextWindow={modelContextWindows[model]}
|
||||
placeholder={
|
||||
agent === "code"
|
||||
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
||||
|
||||
@@ -141,6 +141,9 @@ export interface ConversationMessage {
|
||||
tool_calls?: any[];
|
||||
tool_call_id?: string;
|
||||
source?: MessageSource;
|
||||
// Token counts for the round-trip that produced an assistant message
|
||||
// ({model, input, output, cache_read, cache_write}); absent on older servers.
|
||||
usage?: import("./types").TurnUsage;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -691,6 +694,9 @@ export interface ModelSettings {
|
||||
sessions_peek?: number;
|
||||
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
||||
model_labels?: Record<string, string>;
|
||||
// {full id → context window in tokens}, verified matrix entries only — drives the
|
||||
// composer's context-fill meter (absent id → the meter hides). Optional for older backends.
|
||||
model_context_windows?: Record<string, number>;
|
||||
// Token savings (PDF attachments): fallback for models without native PDF support,
|
||||
// and attach-time thresholds. Optional so the GUI is robust to an older backend.
|
||||
pdf_fallback?: "text" | "images";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { Attachment } from "../types";
|
||||
import type { Attachment, SessionUsage } from "../types";
|
||||
import { isPdfFile, readFile } from "../attach";
|
||||
import { getSettings, inspectPdf } from "../api";
|
||||
import { formatTokens, totalTokens } from "../usage";
|
||||
import { Dropdown, type Option } from "./Dropdown";
|
||||
import { Icon } from "./Icon";
|
||||
import { Toggle } from "./Toggle";
|
||||
@@ -77,6 +78,12 @@ interface Props {
|
||||
resetKey?: string;
|
||||
// Surface-specific hint shown in the empty textarea.
|
||||
placeholder?: string;
|
||||
// Per-session token usage (OPE-42) — absent/empty hides the usage chip entirely
|
||||
// (older servers, backends that don't report usage, fresh sessions).
|
||||
usage?: SessionUsage;
|
||||
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
|
||||
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
export function Composer(props: Props) {
|
||||
@@ -462,6 +469,18 @@ export function Composer(props: Props) {
|
||||
|
||||
<span className="ml-auto" />
|
||||
|
||||
{/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server
|
||||
reports usage. Fill = context-window occupancy (bounded), count = session
|
||||
consumption (unbounded, so never a fill). */}
|
||||
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
|
||||
<UsageChip
|
||||
usage={props.usage}
|
||||
contextWindow={props.contextWindow}
|
||||
model={props.model}
|
||||
modelLabels={props.modelLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
|
||||
mid-session switching shipped, so the picker stays actionable; the topbar
|
||||
subtitle still states the current model). */}
|
||||
@@ -546,6 +565,135 @@ export function Composer(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Token-usage chip + popover (OPE-42). Trigger: a tiny context-fill meter (only when the
|
||||
// active model's window is known) + the session's total token count. Click → per-model
|
||||
// breakdown. Tokens only, never dollars (true cost is unknowable client-side — discounted
|
||||
// pricing, per-provider cache billing).
|
||||
function UsageChip({
|
||||
usage,
|
||||
contextWindow,
|
||||
model,
|
||||
modelLabels,
|
||||
}: {
|
||||
usage: SessionUsage;
|
||||
contextWindow?: number;
|
||||
model: string;
|
||||
modelLabels?: Record<string, string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const total = totalTokens(usage);
|
||||
const pct = contextWindow
|
||||
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
|
||||
: null;
|
||||
const labelFor = (id: string) =>
|
||||
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
|
||||
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
|
||||
// across the whole session, never just the last turn; "Input" is the fresh
|
||||
// (uncached) share — the cached share sits in the cache rows at its own price.
|
||||
const stat = (label: string, value: number) => (
|
||||
<div className="flex items-baseline justify-between text-[11.5px] leading-snug">
|
||||
<span className="text-faint">{label}</span>
|
||||
<span className="text-ink tabular-nums">{formatTokens(value)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11.5px] text-muted hover:text-ink hover:bg-paper shrink-0"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Token usage"
|
||||
title={
|
||||
pct !== null
|
||||
? `Token usage — ${pct}% of the context window used`
|
||||
: "Token usage this session"
|
||||
}
|
||||
data-testid="usage-chip"
|
||||
>
|
||||
{pct !== null && (
|
||||
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||
<span
|
||||
className="block h-full bg-accent transition-all"
|
||||
style={{ width: `${Math.max(pct, 4)}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="absolute z-40 bottom-full mb-1 right-0 w-[280px] rounded-xl border border-line bg-panel shadow-2xl p-3"
|
||||
role="menu"
|
||||
data-testid="usage-popover"
|
||||
>
|
||||
{contextWindow ? (
|
||||
<div className="mb-2.5">
|
||||
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
|
||||
Context window
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-line overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[11.5px] text-muted tabular-nums">
|
||||
{formatTokens(usage.context)} of {formatTokens(contextWindow)} · {pct}%
|
||||
</div>
|
||||
</div>
|
||||
) : usage.context > 0 ? (
|
||||
<div className="mb-2.5 text-[11.5px] text-muted tabular-nums">
|
||||
In context now: {formatTokens(usage.context)} tokens
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
|
||||
Session totals
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{Object.entries(usage.byModel).map(([id, t]) => (
|
||||
<div key={id}>
|
||||
<div className="text-[12px] text-ink font-medium truncate" title={id}>
|
||||
{labelFor(id)}
|
||||
</div>
|
||||
{/* Every row is a session sum. With a cache split, the input rows are
|
||||
the three BILLING CLASSES of input (each priced differently) and
|
||||
read as components: uncached + cache reads + cache writes = total.
|
||||
Without one (Ollama, compat vendors), plain "Input" says it all. */}
|
||||
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||
{t.cache_read + t.cache_write > 0 ? (
|
||||
<>
|
||||
{stat("Uncached input", t.input)}
|
||||
{stat("Cache reads", t.cache_read)}
|
||||
{stat("Cache writes", t.cache_write)}
|
||||
{stat("Total input", t.input + t.cache_read + t.cache_write)}
|
||||
</>
|
||||
) : (
|
||||
stat("Input", t.input)
|
||||
)}
|
||||
{stat("Output", t.output)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-line flex items-baseline justify-between text-[11.5px]">
|
||||
<span className="text-faint">Total</span>
|
||||
<span className="text-ink tabular-nums">{formatTokens(total)} tokens</span>
|
||||
</div>
|
||||
{model && !modelLabels?.[model] && contextWindow === undefined && (
|
||||
<div className="mt-1 text-[10.5px] text-faint leading-snug">
|
||||
Context meter unavailable for custom models.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with
|
||||
// the current one marked, plus — when the session supports it — the "Send approvals to Inbox"
|
||||
// toggle at the bottom (the old standalone InboxControl, folded in).
|
||||
|
||||
@@ -39,6 +39,25 @@ export interface TodoItem {
|
||||
status: "pending" | "in_progress" | "done";
|
||||
}
|
||||
|
||||
// Per-round-trip token counts, as attached by the server to assistant messages and
|
||||
// the assistant_message event (`{model, input, output, cache_read, cache_write}`).
|
||||
// Absent on older servers and on backends that don't report usage.
|
||||
export interface TurnUsage {
|
||||
model?: string | null;
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read: number;
|
||||
cache_write: number;
|
||||
}
|
||||
|
||||
// Per-session accumulation, keyed by model id (multiple models when the user
|
||||
// switched mid-session). `context` = the latest round-trip's prompt-side total —
|
||||
// what currently occupies the active model's context window.
|
||||
export interface SessionUsage {
|
||||
byModel: Record<string, TurnUsage>;
|
||||
context: number;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
title?: string;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addTurnUsage, emptyUsage, formatTokens, totalTokens, usageFromMessages } from "./usage";
|
||||
|
||||
const turn = (over: Record<string, unknown> = {}) => ({
|
||||
model: "anthropic:claude-fable-5",
|
||||
input: 100,
|
||||
output: 20,
|
||||
cache_read: 50,
|
||||
cache_write: 10,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("addTurnUsage", () => {
|
||||
it("accumulates per model and tracks the latest prompt-side total as context", () => {
|
||||
let u = addTurnUsage(emptyUsage(), turn());
|
||||
u = addTurnUsage(u, turn({ input: 40, output: 5, cache_read: 200, cache_write: 0 }));
|
||||
const m = u.byModel["anthropic:claude-fable-5"];
|
||||
expect(m).toEqual({
|
||||
model: "anthropic:claude-fable-5",
|
||||
input: 140,
|
||||
output: 25,
|
||||
cache_read: 250,
|
||||
cache_write: 10,
|
||||
});
|
||||
// context = LAST turn's input + cache_read + cache_write, not a sum.
|
||||
expect(u.context).toBe(240);
|
||||
});
|
||||
|
||||
it("keys separate models separately (mid-session switch)", () => {
|
||||
let u = addTurnUsage(emptyUsage(), turn());
|
||||
u = addTurnUsage(u, turn({ model: "gpt-5.5", input: 7 }));
|
||||
expect(Object.keys(u.byModel).sort()).toEqual(["anthropic:claude-fable-5", "gpt-5.5"]);
|
||||
});
|
||||
|
||||
it("ignores malformed payloads and clamps negatives", () => {
|
||||
expect(addTurnUsage(emptyUsage(), null)).toEqual(emptyUsage());
|
||||
expect(addTurnUsage(emptyUsage(), "x")).toEqual(emptyUsage());
|
||||
const u = addTurnUsage(emptyUsage(), turn({ input: -5, output: "9" }));
|
||||
expect(u.byModel["anthropic:claude-fable-5"].input).toBe(0);
|
||||
expect(u.byModel["anthropic:claude-fable-5"].output).toBe(9);
|
||||
});
|
||||
|
||||
it("buckets a missing model id under 'unknown'", () => {
|
||||
const u = addTurnUsage(emptyUsage(), turn({ model: undefined }));
|
||||
expect(u.byModel["unknown"].input).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usageFromMessages", () => {
|
||||
it("folds assistant usage sidecars and ignores everything else", () => {
|
||||
const u = usageFromMessages([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "a", usage: turn() as any },
|
||||
{ role: "tool", content: "r", usage: turn() as any }, // wrong role — ignored
|
||||
{ role: "assistant", content: "b" }, // no sidecar (older server) — ignored
|
||||
{ role: "assistant", content: "c", usage: turn({ input: 300 }) as any },
|
||||
]);
|
||||
expect(u.byModel["anthropic:claude-fable-5"].input).toBe(400);
|
||||
expect(u.context).toBe(360);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalTokens / formatTokens", () => {
|
||||
it("totals across models and directions", () => {
|
||||
let u = addTurnUsage(emptyUsage(), turn());
|
||||
u = addTurnUsage(u, turn({ model: "gpt-5.5" }));
|
||||
expect(totalTokens(u)).toBe(360);
|
||||
});
|
||||
|
||||
it("formats humane numbers", () => {
|
||||
expect(formatTokens(0)).toBe("0");
|
||||
expect(formatTokens(980)).toBe("980");
|
||||
expect(formatTokens(12_400)).toBe("12.4k");
|
||||
expect(formatTokens(982_000)).toBe("982k");
|
||||
expect(formatTokens(1_240_000)).toBe("1.24M");
|
||||
expect(formatTokens(NaN)).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Per-session token-usage accumulation (OPE-42). Pure functions so the reducer is
|
||||
// unit-testable without the app. The server attaches a `usage` sidecar
|
||||
// ({model, input, output, cache_read, cache_write}) to assistant messages and to the
|
||||
// assistant_message event; older servers and non-reporting backends send none, and
|
||||
// everything here no-ops gracefully in that case.
|
||||
|
||||
import type { ConversationMessage } from "./api";
|
||||
import type { SessionUsage, TurnUsage } from "./types";
|
||||
|
||||
export function emptyUsage(): SessionUsage {
|
||||
return { byModel: {}, context: 0 };
|
||||
}
|
||||
|
||||
const num = (v: any): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
};
|
||||
|
||||
/** Fold one turn's usage sidecar into the session accumulation. */
|
||||
export function addTurnUsage(prev: SessionUsage, raw: any): SessionUsage {
|
||||
if (!raw || typeof raw !== "object") return prev;
|
||||
const turn: TurnUsage = {
|
||||
model: typeof raw.model === "string" && raw.model ? raw.model : null,
|
||||
input: num(raw.input),
|
||||
output: num(raw.output),
|
||||
cache_read: num(raw.cache_read),
|
||||
cache_write: num(raw.cache_write),
|
||||
};
|
||||
const key = turn.model || "unknown";
|
||||
const cur = prev.byModel[key];
|
||||
return {
|
||||
byModel: {
|
||||
...prev.byModel,
|
||||
[key]: {
|
||||
model: turn.model,
|
||||
input: (cur?.input || 0) + turn.input,
|
||||
output: (cur?.output || 0) + turn.output,
|
||||
cache_read: (cur?.cache_read || 0) + turn.cache_read,
|
||||
cache_write: (cur?.cache_write || 0) + turn.cache_write,
|
||||
},
|
||||
},
|
||||
// Prompt-side total of the LATEST round-trip = what currently sits in the
|
||||
// context window (not a sum — each request resends the whole history).
|
||||
context: turn.input + turn.cache_read + turn.cache_write,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rebuild the accumulation from a replayed transcript (session load/switch). */
|
||||
export function usageFromMessages(messages: ConversationMessage[]): SessionUsage {
|
||||
let acc = emptyUsage();
|
||||
for (const m of messages || []) {
|
||||
if (m.role === "assistant" && m.usage) acc = addTurnUsage(acc, m.usage);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** All tokens consumed this session, across models and directions (chip headline). */
|
||||
export function totalTokens(u: SessionUsage): number {
|
||||
return Object.values(u.byModel).reduce(
|
||||
(sum, t) => sum + t.input + t.output + t.cache_read + t.cache_write,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** 980 → "980", 12_400 → "12.4k", 982_000 → "982k", 1_240_000 → "1.24M". */
|
||||
export function formatTokens(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0";
|
||||
if (n < 1000) return String(Math.round(n));
|
||||
if (n < 1_000_000) {
|
||||
const k = n / 1000;
|
||||
return (k < 100 ? k.toFixed(1).replace(/\.0$/, "") : String(Math.round(k))) + "k";
|
||||
}
|
||||
const m = n / 1_000_000;
|
||||
return (m < 100 ? m.toFixed(2).replace(/\.?0+$/, "") : String(Math.round(m))) + "M";
|
||||
}
|
||||
Reference in New Issue
Block a user