diff --git a/docs/i18n.md b/docs/i18n.md
index b3653a8b1d..0e132c89a8 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -91,3 +91,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
- Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales.
+- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Provider names, plan labels, usage-window labels, balance/detail labels, source labels, and raw provider errors remain runtime values.
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index d29de2b33d..869a26a88c 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1950,6 +1950,43 @@ export const ar: TranslationResources = {
saveAccessibility: "حفظ تعليق المراجعة",
},
},
+ providerUsage: {
+ title: "استخدام الخطة",
+ refresh: "تحديث",
+ refreshing: "جارٍ التحديث...",
+ loading: "جارٍ تحميل بيانات الاستخدام...",
+ empty: "لا توجد بيانات استخدام",
+ errorTitle: "تعذّر تحميل بيانات الاستخدام",
+ hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد",
+ hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد",
+ clientUnavailable: "اتصال المضيف غير جاهز",
+ retry: "حاول مرة أخرى",
+ tooltipLoading: "جارٍ تحميل استخدام الخطة...",
+ status: {
+ error: "خطأ",
+ unavailable: "غير متاح",
+ },
+ values: {
+ used: "تم استخدام {{percentage}}",
+ remaining: "متبقٍ {{amount}}",
+ },
+ duration: {
+ days: "{{value}} يوم",
+ hours: "{{value}} ساعة",
+ minutes: "{{value}} دقيقة",
+ },
+ timing: {
+ resettingNow: "تتم إعادة التعيين الآن",
+ resetsIn: "تتم إعادة التعيين خلال {{duration}}",
+ runsOutNow: "ينفد الآن",
+ runsOutIn: "ينفد خلال {{duration}}",
+ justNow: "الآن",
+ daysAgo: "قبل {{value}} يومًا",
+ hoursAgo: "قبل {{value}} ساعة",
+ minutesAgo: "قبل {{value}} دقيقة",
+ updated: "تم التحديث {{relativeTime}}",
+ },
+ },
settings: {
title: "إعدادات",
loading: "جارٍ تحميل الإعدادات...",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 1e2d4c3f10..13544c4d09 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1975,6 +1975,43 @@ export const en = {
saveAccessibility: "Save review comment",
},
},
+ providerUsage: {
+ title: "Plan usage",
+ refresh: "Refresh",
+ refreshing: "Refreshing...",
+ loading: "Loading usage...",
+ empty: "No usage data",
+ errorTitle: "Unable to load usage",
+ hostUnavailable: "Connect to this host to see provider usage",
+ hostUpgradeRequired: "Update the host to see provider usage",
+ clientUnavailable: "Host connection is not ready",
+ retry: "Try again",
+ tooltipLoading: "Loading plan usage...",
+ status: {
+ error: "Error",
+ unavailable: "Unavailable",
+ },
+ values: {
+ used: "{{percentage}} used",
+ remaining: "{{amount}} left",
+ },
+ duration: {
+ days: "{{value}}d",
+ hours: "{{value}}h",
+ minutes: "{{value}}m",
+ },
+ timing: {
+ resettingNow: "resetting now",
+ resetsIn: "resets {{duration}}",
+ runsOutNow: "runs out now",
+ runsOutIn: "runs out {{duration}}",
+ justNow: "just now",
+ daysAgo: "{{value}}d ago",
+ hoursAgo: "{{value}}h ago",
+ minutesAgo: "{{value}}m ago",
+ updated: "Updated {{relativeTime}}",
+ },
+ },
settings: {
title: "Settings",
loading: "Loading settings...",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index cc37432b5b..718499f853 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1998,6 +1998,43 @@ export const es: TranslationResources = {
saveAccessibility: "Guardar comentario de revisión",
},
},
+ providerUsage: {
+ title: "Uso del plan",
+ refresh: "Actualizar",
+ refreshing: "Actualizando...",
+ loading: "Cargando uso...",
+ empty: "No hay datos de uso",
+ errorTitle: "No se pudo cargar el uso",
+ hostUnavailable: "Conéctate a este host para ver el uso del proveedor",
+ hostUpgradeRequired: "Actualiza el host para ver el uso del proveedor",
+ clientUnavailable: "La conexión con el host no está lista",
+ retry: "Intentar de nuevo",
+ tooltipLoading: "Cargando uso del plan...",
+ status: {
+ error: "Error",
+ unavailable: "No disponible",
+ },
+ values: {
+ used: "Usado: {{percentage}}",
+ remaining: "Quedan {{amount}}",
+ },
+ duration: {
+ days: "{{value}} d",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ resettingNow: "se restablece ahora",
+ resetsIn: "se restablece en {{duration}}",
+ runsOutNow: "se agota ahora",
+ runsOutIn: "se agota en {{duration}}",
+ justNow: "ahora mismo",
+ daysAgo: "hace {{value}} d",
+ hoursAgo: "hace {{value}} h",
+ minutesAgo: "hace {{value}} min",
+ updated: "Actualizado {{relativeTime}}",
+ },
+ },
settings: {
title: "Ajustes",
loading: "Cargando configuración...",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index aaf2608f9e..442c5c940c 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -2003,6 +2003,43 @@ export const fr: TranslationResources = {
saveAccessibility: "Enregistrer le commentaire de l'avis",
},
},
+ providerUsage: {
+ title: "Utilisation du forfait",
+ refresh: "Actualiser",
+ refreshing: "Actualisation...",
+ loading: "Chargement de l’utilisation...",
+ empty: "Aucune donnée d’utilisation",
+ errorTitle: "Impossible de charger l’utilisation",
+ hostUnavailable: "Connectez-vous à cet hôte pour voir l’utilisation du fournisseur",
+ hostUpgradeRequired: "Mettez à jour l’hôte pour voir l’utilisation du fournisseur",
+ clientUnavailable: "La connexion à l’hôte n’est pas prête",
+ retry: "Réessayer",
+ tooltipLoading: "Chargement de l’utilisation du forfait...",
+ status: {
+ error: "Erreur",
+ unavailable: "Indisponible",
+ },
+ values: {
+ used: "Utilisé : {{percentage}}",
+ remaining: "Il reste {{amount}}",
+ },
+ duration: {
+ days: "{{value}} j",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ resettingNow: "réinitialisation en cours",
+ resetsIn: "se réinitialise dans {{duration}}",
+ runsOutNow: "s’épuise maintenant",
+ runsOutIn: "s’épuise dans {{duration}}",
+ justNow: "à l’instant",
+ daysAgo: "il y a {{value}} j",
+ hoursAgo: "il y a {{value}} h",
+ minutesAgo: "il y a {{value}} min",
+ updated: "Mis à jour {{relativeTime}}",
+ },
+ },
settings: {
title: "Paramètres",
loading: "Chargement des paramètres...",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 3491b507a2..6e449075d9 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -1970,6 +1970,43 @@ export const ja: TranslationResources = {
saveAccessibility: "レビューコメントを保存",
},
},
+ providerUsage: {
+ title: "プラン使用量",
+ refresh: "更新",
+ refreshing: "更新中...",
+ loading: "使用状況を読み込み中...",
+ empty: "使用状況データがありません",
+ errorTitle: "使用状況を読み込めません",
+ hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください",
+ hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください",
+ clientUnavailable: "ホスト接続の準備ができていません",
+ retry: "再試行",
+ tooltipLoading: "プラン使用状況を読み込み中...",
+ status: {
+ error: "エラー",
+ unavailable: "利用不可",
+ },
+ values: {
+ used: "{{percentage}} 使用済み",
+ remaining: "残り {{amount}}",
+ },
+ duration: {
+ days: "{{value}}日",
+ hours: "{{value}}時間",
+ minutes: "{{value}}分",
+ },
+ timing: {
+ resettingNow: "まもなくリセット",
+ resetsIn: "{{duration}}後にリセット",
+ runsOutNow: "まもなく上限に到達",
+ runsOutIn: "{{duration}}後に上限に到達",
+ justNow: "たった今",
+ daysAgo: "{{value}}日前",
+ hoursAgo: "{{value}}時間前",
+ minutesAgo: "{{value}}分前",
+ updated: "更新: {{relativeTime}}",
+ },
+ },
settings: {
title: "設定",
loading: "設定を読み込み中...",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index a08088b2e4..bae71a564a 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -1983,6 +1983,43 @@ export const ptBR: TranslationResources = {
saveAccessibility: "Salvar comentário de revisão",
},
},
+ providerUsage: {
+ title: "Uso do plano",
+ refresh: "Atualizar",
+ refreshing: "Atualizando...",
+ loading: "Carregando uso...",
+ empty: "Sem dados de uso",
+ errorTitle: "Não foi possível carregar o uso",
+ hostUnavailable: "Conecte-se a este host para ver o uso do provedor",
+ hostUpgradeRequired: "Atualize o host para ver o uso do provedor",
+ clientUnavailable: "A conexão com o host não está pronta",
+ retry: "Tentar novamente",
+ tooltipLoading: "Carregando uso do plano...",
+ status: {
+ error: "Erro",
+ unavailable: "Indisponível",
+ },
+ values: {
+ used: "{{percentage}} usado",
+ remaining: "{{amount}} restantes",
+ },
+ duration: {
+ days: "{{value}} d",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ resettingNow: "redefinindo agora",
+ resetsIn: "redefine em {{duration}}",
+ runsOutNow: "esgota agora",
+ runsOutIn: "esgota em {{duration}}",
+ justNow: "agora mesmo",
+ daysAgo: "há {{value}} d",
+ hoursAgo: "há {{value}} h",
+ minutesAgo: "há {{value}} min",
+ updated: "Atualizado {{relativeTime}}",
+ },
+ },
settings: {
title: "Configurações",
loading: "Carregando configurações...",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 4526784896..5eac9f746f 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1983,6 +1983,43 @@ export const ru: TranslationResources = {
saveAccessibility: "Сохранить комментарий к ревью",
},
},
+ providerUsage: {
+ title: "Использование плана",
+ refresh: "Обновить",
+ refreshing: "Обновление...",
+ loading: "Загрузка данных об использовании...",
+ empty: "Нет данных об использовании",
+ errorTitle: "Не удалось загрузить данные об использовании",
+ hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера",
+ hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера",
+ clientUnavailable: "Соединение с хостом не готово",
+ retry: "Повторить",
+ tooltipLoading: "Загрузка использования плана...",
+ status: {
+ error: "Ошибка",
+ unavailable: "Недоступно",
+ },
+ values: {
+ used: "Использовано: {{percentage}}",
+ remaining: "Осталось: {{amount}}",
+ },
+ duration: {
+ days: "{{value}} дн.",
+ hours: "{{value}} ч",
+ minutes: "{{value}} мин",
+ },
+ timing: {
+ resettingNow: "сброс сейчас",
+ resetsIn: "сброс через {{duration}}",
+ runsOutNow: "закончится сейчас",
+ runsOutIn: "закончится через {{duration}}",
+ justNow: "только что",
+ daysAgo: "{{value}} дн. назад",
+ hoursAgo: "{{value}} ч назад",
+ minutesAgo: "{{value}} мин назад",
+ updated: "Обновлено {{relativeTime}}",
+ },
+ },
settings: {
title: "Настройки",
loading: "Загрузка настроек...",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index d13007dbea..3f12ac2005 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1928,6 +1928,43 @@ export const zhCN: TranslationResources = {
saveAccessibility: "保存 review 评论",
},
},
+ providerUsage: {
+ title: "套餐用量",
+ refresh: "刷新",
+ refreshing: "刷新中...",
+ loading: "正在加载用量...",
+ empty: "暂无用量数据",
+ errorTitle: "无法加载用量",
+ hostUnavailable: "连接到此主机以查看提供商用量",
+ hostUpgradeRequired: "更新主机以查看提供商用量",
+ clientUnavailable: "主机连接尚未就绪",
+ retry: "重试",
+ tooltipLoading: "正在加载套餐用量...",
+ status: {
+ error: "错误",
+ unavailable: "不可用",
+ },
+ values: {
+ used: "已使用 {{percentage}}",
+ remaining: "剩余 {{amount}}",
+ },
+ duration: {
+ days: "{{value}}天",
+ hours: "{{value}}小时",
+ minutes: "{{value}}分钟",
+ },
+ timing: {
+ resettingNow: "正在重置",
+ resetsIn: "将在 {{duration}}后重置",
+ runsOutNow: "即将用尽",
+ runsOutIn: "将在 {{duration}}后用尽",
+ justNow: "刚刚",
+ daysAgo: "{{value}}天前",
+ hoursAgo: "{{value}}小时前",
+ minutesAgo: "{{value}}分钟前",
+ updated: "更新于 {{relativeTime}}",
+ },
+ },
settings: {
title: "设置",
loading: "正在加载设置...",
diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx
index 751193eec0..e25448f0d3 100644
--- a/packages/app/src/usage/balance-bar.tsx
+++ b/packages/app/src/usage/balance-bar.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -10,7 +11,11 @@ interface ResolvedBalance {
usedPct: number | null;
}
-function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
+function resolveBalance(
+ balance: UsageBalance,
+ t: TFunction,
+ locale?: string,
+): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
@@ -20,7 +25,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
- return { amountText: `${format(remaining)} left`, usedPct: null };
+ return {
+ amountText: t("providerUsage.values.remaining", { amount: format(remaining) }),
+ usedPct: null,
+ };
}
if (used != null) {
return { amountText: format(used), usedPct: null };
@@ -42,8 +50,8 @@ function fillToneStyle(tone: UsageTone) {
}
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
- const { i18n } = useTranslation();
- const { amountText, usedPct } = resolveBalance(balance, i18n.language);
+ const { t, i18n } = useTranslation();
+ const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
@@ -58,7 +66,7 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
{balance.label}
-
+
{amountText}
{resetLabel ? {` · ${resetLabel}`} : null}
@@ -88,6 +96,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
+ flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts
index 88fe227ae6..cda2b9d9d4 100644
--- a/packages/app/src/usage/format.test.ts
+++ b/packages/app/src/usage/format.test.ts
@@ -1,7 +1,17 @@
-import { describe, expect, it } from "vitest";
-import { formatAmount } from "./format";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { i18n } from "@/i18n/i18next";
+import { formatAgo, formatAmount, formatPct, formatResetLabel, formatRunsOutLabel } from "./format";
+
+const NOW = Date.parse("2026-07-19T00:00:00.000Z");
describe("formatAmount", () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage("en");
+ });
+
+ afterEach(async () => {
+ await i18n.changeLanguage("en");
+ });
it("groups thousands in the app's language", () => {
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
@@ -12,4 +22,18 @@ describe("formatAmount", () => {
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
+
+ it("localizes usage timing without patching the global clock", async () => {
+ const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
+ const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
+
+ expect(formatPct(7, "en")).toBe("7%");
+ expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("resets in 2h");
+ expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
+
+ await i18n.changeLanguage("ja");
+ expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("2時間後にリセット");
+ expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達");
+ expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前");
+ });
});
diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts
index 5adef2154d..c99a7a07a8 100644
--- a/packages/app/src/usage/format.ts
+++ b/packages/app/src/usage/format.ts
@@ -1,46 +1,139 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
+import { i18n } from "@/i18n/i18next";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
+const providerUsageLabelKeys = {
+ session: "providerUsage.labels.session",
+ five_hour: "providerUsage.labels.session",
+ weekly: "providerUsage.labels.weekly",
+ monthly: "providerUsage.labels.monthly",
+ code_review: "providerUsage.labels.codeReview",
+ credits: "providerUsage.labels.credits",
+ monthly_credits: "providerUsage.labels.monthlyCredits",
+} as const;
+
+export function formatProviderUsageLabel(id: string, fallback: string): string {
+ const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys];
+ return key ? i18n.t(key) : fallback;
+}
+
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
}
-export function formatPct(value: number): string {
- return `${Math.round(clampPct(value))}%`;
+export function formatPct(value: number, locale?: string): string {
+ return new Intl.NumberFormat(locale, {
+ style: "percent",
+ maximumFractionDigits: 0,
+ }).format(clampPct(value) / 100);
}
-/** "31%" of the window used, or "69% left" of it. */
-export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string {
- return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`;
+/** A localized percentage that says whether it is used or remaining. */
+export function formatDisplayPct(value: number, displayAs: UsageDisplayAs, locale?: string): string {
+ const percentage = formatPct(value, locale);
+ return displayAs === "used"
+ ? i18n.t("providerUsage.values.used", { percentage })
+ : i18n.t("providerUsage.values.remaining", { amount: percentage });
}
-function relativeDuration(iso: string): string | null {
- const diffMs = new Date(iso).getTime() - Date.now();
+type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
+
+function formatCount(value: number): string {
+ return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
+}
+
+function relativeDuration(iso: string, now: number): RelativeDuration | null {
+ const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
- if (diffMs <= 0) return "now";
+ if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
- if (diffDays > 0) return `${diffDays}d`;
- if (diffHours > 0) return `${diffHours}h`;
- return `${diffMinutes}m`;
+ if (diffDays > 0) return { unit: "days", count: diffDays };
+ if (diffHours > 0) return { unit: "hours", count: diffHours };
+ return { unit: "minutes", count: diffMinutes };
}
-export function formatResetLabel(iso: string | null | undefined): string | null {
+function formatRelativeDuration(duration: RelativeDuration): string | null {
+ switch (duration.unit) {
+ case "now":
+ return null;
+ case "days":
+ return i18n.t("providerUsage.duration.days", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ case "hours":
+ return i18n.t("providerUsage.duration.hours", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ case "minutes":
+ return i18n.t("providerUsage.duration.minutes", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ }
+}
+
+export function formatResetLabel(iso: string | null | undefined, now = Date.now()): string | null {
if (!iso) return null;
- const rel = relativeDuration(iso);
+ const rel = relativeDuration(iso, now);
if (!rel) return null;
- return rel === "now" ? "resetting now" : `resets ${rel}`;
+ if (rel.unit === "now") return i18n.t("providerUsage.timing.resettingNow");
+ const duration = formatRelativeDuration(rel);
+ return duration ? i18n.t("providerUsage.timing.resetsIn", { duration }) : null;
+}
+
+export function formatRunsOutLabel(
+ iso: string | null | undefined,
+ now = Date.now(),
+): string | null {
+ if (!iso) return null;
+ const rel = relativeDuration(iso, now);
+ if (!rel) return null;
+ if (rel.unit === "now") return i18n.t("providerUsage.timing.runsOutNow");
+ const duration = formatRelativeDuration(rel);
+ return duration ? i18n.t("providerUsage.timing.runsOutIn", { duration }) : null;
+}
+
+export function formatAgo(iso: string | null | undefined, now = Date.now()): string | null {
+ if (!iso) return null;
+ const diffMs = now - new Date(iso).getTime();
+ if (!Number.isFinite(diffMs)) return null;
+ if (diffMs < 60_000) return i18n.t("providerUsage.timing.justNow");
+ const diffMinutes = Math.floor(diffMs / 60_000);
+ const diffHours = Math.floor(diffMinutes / 60);
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays > 0) {
+ return i18n.t("providerUsage.timing.daysAgo", {
+ count: diffDays,
+ value: formatCount(diffDays),
+ });
+ }
+ if (diffHours > 0) {
+ return i18n.t("providerUsage.timing.hoursAgo", {
+ count: diffHours,
+ value: formatCount(diffHours),
+ });
+ }
+ return i18n.t("providerUsage.timing.minutesAgo", {
+ count: diffMinutes,
+ value: formatCount(diffMinutes),
+ });
}
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
-export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
+export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string {
switch (unit) {
case "usd":
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
case "tokens":
- return formatTokenCount(value);
+ return new Intl.NumberFormat(locale, {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ }).format(value);
default:
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx
index 8b668eab00..35525e1c36 100644
--- a/packages/app/src/usage/window-bar.tsx
+++ b/packages/app/src/usage/window-bar.tsx
@@ -1,10 +1,16 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usageCopy } from "./copy";
-import { formatDisplayPct, formatResetLabel } from "./format";
+import {
+ formatDisplayPct,
+ formatProviderUsageLabel,
+ formatResetLabel,
+ formatRunsOutLabel,
+} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import type { UsageDisplayAs } from "./preferences";
@@ -34,15 +40,17 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
+ const { i18n } = useTranslation();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
const trailing = isAtRisk
- ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
+ ? formatRunsOutLabel(window.runsOutAt)
: formatResetLabel(window.resetsAt);
- const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
+ const value =
+ shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
@@ -60,7 +68,7 @@ export function UsageWindowBar({
{({ hovered }: { hovered?: boolean }) => (
{label}
-
+
{value}
{trailing ? (
{` · ${trailing}`}
@@ -186,6 +194,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
+ flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,