diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts
index 5d024de761..79a572a24b 100644
--- a/packages/app/e2e/browser/context-window-meter.spec.ts
+++ b/packages/app/e2e/browser/context-window-meter.spec.ts
@@ -149,7 +149,7 @@ for (const layout of [
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
- await expect(account.getByText("31%", { exact: true })).toBeVisible();
+ await expect(account.getByText("31% used", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
@@ -161,7 +161,7 @@ for (const layout of [
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
- usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
+ usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
).toBeVisible();
});
} finally {
diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts
index f51c0ad510..7aafc40b62 100644
--- a/packages/app/e2e/browser/usage-modal.spec.ts
+++ b/packages/app/e2e/browser/usage-modal.spec.ts
@@ -4,9 +4,10 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
-import { gotoAppShell } from "../support/helpers/app";
+import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
+import { openSettingsSection } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
- await expect(group.getByText("41%", { exact: true })).toBeVisible();
+ await expect(group.getByText("41% used", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
- await expect(group.getByText("31%")).toBeVisible();
+ await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
- await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
+ await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
- await expect(group.getByText("58%")).toBeVisible();
- await expect(group.getByText("12%")).toBeVisible();
+ await expect(group.getByText("58% used")).toBeVisible();
+ await expect(group.getByText("12% used")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
- page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
+ page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
- page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
+ page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
- /^Pin Claude Session, 31% · resets /,
+ /^Pin Claude Session, 31% used · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
- await expectLoginUsageOnly(page, "42%");
+ await expectLoginUsageOnly(page, "42% used");
} finally {
await fixture.cleanup();
}
@@ -615,7 +616,7 @@ for (const compact of [false, true]) {
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
- await expectLoginUsageOnly(page, "42%");
+ await expectLoginUsageOnly(page, "42% used");
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -640,8 +641,8 @@ for (const compact of [false, true]) {
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
- await expectLoginUsageOnly(page, "43%");
- await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
+ await expectLoginUsageOnly(page, "43% used");
+ await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
@@ -651,7 +652,44 @@ for (const compact of [false, true]) {
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
- await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
+ await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
+
+async function switchToJapanese(page: Page) {
+ await page.getByRole("button", { name: "Close", exact: true }).click();
+ await openSettings(page);
+ await openSettingsSection(page, "general");
+ await page.getByRole("button", { name: "System", exact: true }).click();
+ await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
+ await page.getByRole("button", { name: "戻る", exact: true }).click();
+}
+
+async function openJapaneseUsage(page: Page) {
+ await page.getByRole("button", { name: "使用状況", exact: true }).click();
+ await expect(page.getByTestId("usage-modal").first()).toBeVisible();
+}
+
+test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
+ test.setTimeout(120_000);
+ const login = await installLoginUsage({
+ status: "available",
+ windows: [{ id: "session", label: "Session", usedPct: 70 }],
+ });
+ try {
+ await gotoAppShell(page);
+ await openUsageFromIcon(page);
+ await expect(page.getByText("70% used", { exact: true })).toBeVisible();
+ await switchToJapanese(page);
+ await openJapaneseUsage(page);
+ await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "設定", exact: true }).last().click();
+ await expect(page.getByText("割合", { exact: true })).toBeVisible();
+ await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
+ await expect(page.getByText("残り", { exact: true })).toBeVisible();
+ } finally {
+ await login.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts
index 1b509f5640..15dd2fdb7a 100644
--- a/packages/app/e2e/browser/usage-settings.spec.ts
+++ b/packages/app/e2e/browser/usage-settings.spec.ts
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
- await expect(card.getByText("70%")).toBeVisible();
+ await expect(card.getByText("70% used")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("usage-card");
- await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
+ await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
- await expect(card.getByText("64%")).toBeVisible();
+ await expect(card.getByText("64% used")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx
index 11931bdd41..f9cfabcf8d 100644
--- a/packages/app/src/components/context-window-meter.tsx
+++ b/packages/app/src/components/context-window-meter.tsx
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
+import { formatContextPercentage } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
serverId: string;
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
);
const percentageLabel =
showPercentage && context ? (
- {`${context.percentage}%`}
+
+ {formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
+
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
diff --git a/packages/app/src/components/context-window-meter.utils.test.ts b/packages/app/src/components/context-window-meter.utils.test.ts
new file mode 100644
index 0000000000..394c933176
--- /dev/null
+++ b/packages/app/src/components/context-window-meter.utils.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+import { formatContextPercentage } from "./context-window-meter.utils";
+
+describe("context window meter formatting", () => {
+ it("localizes percentages without hiding context overflow", () => {
+ expect(formatContextPercentage(125, "en")).toBe("125%");
+ expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
+ });
+});
diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts
index a0fa45ccbf..055c01386c 100644
--- a/packages/app/src/components/context-window-meter.utils.ts
+++ b/packages/app/src/components/context-window-meter.utils.ts
@@ -1,3 +1,10 @@
+export function formatContextPercentage(value: number, locale?: string): string {
+ return new Intl.NumberFormat(locale, {
+ style: "percent",
+ maximumFractionDigits: 0,
+ }).format(value / 100);
+}
+
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts
index bf42a3b61a..c837815a7c 100644
--- a/packages/app/src/i18n/resources.test.ts
+++ b/packages/app/src/i18n/resources.test.ts
@@ -20,6 +20,30 @@ function flattenKeys(value: unknown, prefix = ""): string[] {
return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key));
}
+const pluralSuffixPattern = /_(zero|one|two|few|many|other)$/;
+const arabicPluralSuffixes = ["zero", "one", "two", "few", "many", "other"] as const;
+const arabicProviderUsagePluralKeys = [
+ "providerUsage.duration.days",
+ "providerUsage.duration.hours",
+ "providerUsage.duration.minutes",
+ "providerUsage.timing.daysAgo",
+ "providerUsage.timing.hoursAgo",
+ "providerUsage.timing.minutesAgo",
+] as const;
+
+function canonicalKeys(value: unknown): string[] {
+ const keys = flattenKeys(value);
+ const keySet = new Set(keys);
+ return [
+ ...new Set(
+ keys.map((key) => {
+ const baseKey = key.replace(pluralSuffixPattern, "");
+ return baseKey !== key && keySet.has(baseKey) ? baseKey : key;
+ }),
+ ),
+ ].sort();
+}
+
function flattenStrings(value: unknown, prefix = ""): Record {
if (typeof value === "string") {
return { [prefix]: value };
@@ -144,15 +168,24 @@ function findUntranslatedConnectionErrors(): string[] {
describe("translation resources", () => {
it("keeps all supported language keys in sync with English", () => {
- const englishKeys = flattenKeys(en).sort();
- expect(flattenKeys(ar).sort()).toEqual(englishKeys);
- expect(flattenKeys(es).sort()).toEqual(englishKeys);
- expect(flattenKeys(fr).sort()).toEqual(englishKeys);
- expect(flattenKeys(ja).sort()).toEqual(englishKeys);
- expect(flattenKeys(ko).sort()).toEqual(englishKeys);
- expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
- expect(flattenKeys(ru).sort()).toEqual(englishKeys);
- expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
+ const englishKeys = canonicalKeys(en);
+ expect(canonicalKeys(ar)).toEqual(englishKeys);
+ expect(canonicalKeys(es)).toEqual(englishKeys);
+ expect(canonicalKeys(fr)).toEqual(englishKeys);
+ expect(canonicalKeys(ja)).toEqual(englishKeys);
+ expect(canonicalKeys(ko)).toEqual(englishKeys);
+ expect(canonicalKeys(ptBR)).toEqual(englishKeys);
+ expect(canonicalKeys(ru)).toEqual(englishKeys);
+ expect(canonicalKeys(zhCN)).toEqual(englishKeys);
+ });
+
+ it("includes every Arabic form for provider-usage durations", () => {
+ const arabicStrings = flattenStrings(ar);
+ for (const key of arabicProviderUsagePluralKeys) {
+ for (const suffix of arabicPluralSuffixes) {
+ expect(arabicStrings[`${key}_${suffix}`]).toBeTypeOf("string");
+ }
+ }
});
it("keeps non-English supported languages translated beyond fallback labels", () => {
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 4adf945b61..e2dd586044 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -2045,6 +2045,106 @@ export const ar: TranslationResources = {
saveAccessibility: "حفظ تعليق المراجعة",
},
},
+ providerUsage: {
+ options: "الإعدادات",
+ refreshFailed: "تعذّر تحديث الاستخدام",
+ noHosts: "لا توجد مضيفات متصلة",
+ agentError: "تعذّر تحميل الاستخدام: {{reason}}",
+ pin: "تثبيت",
+ unpin: "إلغاء التثبيت",
+ displayAs: "النسب المئوية",
+ displayUsed: "المستخدم",
+ displayRemaining: "المتبقي",
+ showInSidebar: "ملخص في الشريط الجانبي",
+ showInSidebarHint: "تظهر الفترات المثبتة في أسفل الشريط الجانبي",
+ runToRefresh: "شغّل {{command}} للتحديث.",
+ signInAgain: "سجّل الدخول مجددًا.",
+ loginRejected: "تم رفض تسجيل الدخول (HTTP {{status}}). {{remedy}}",
+ loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
+ title: "استخدام الخطة",
+ refresh: "تحديث",
+ refreshAll: "تحديث الكل",
+ refreshing: "جارٍ التحديث...",
+ loading: "جارٍ تحميل بيانات الاستخدام...",
+ empty: "لا توجد بيانات استخدام",
+ errorTitle: "تعذّر تحميل بيانات الاستخدام",
+ hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد",
+ hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد",
+ clientUnavailable: "اتصال المضيف غير جاهز",
+ retry: "حاول مرة أخرى",
+ tooltipLoading: "جارٍ تحميل استخدام الخطة...",
+ status: {
+ error: "خطأ",
+ unavailable: "غير متاح",
+ },
+ values: {
+ used: "تم استخدام {{percentage}}",
+ remaining: "متبقٍ {{amount}}",
+ },
+ labels: {
+ session: "الجلسة",
+ weekly: "أسبوعي",
+ monthly: "شهري",
+ codeReview: "مراجعة التعليمات البرمجية",
+ credits: "الأرصدة",
+ monthlyCredits: "أرصدة شهرية",
+ },
+ duration: {
+ days: "{{value}} يوم",
+ days_zero: "{{value}} يوم",
+ days_one: "يوم واحد",
+ days_two: "يومين",
+ days_few: "{{value}} أيام",
+ days_many: "{{value}} يومًا",
+ days_other: "{{value}} يوم",
+ hours: "{{value}} ساعة",
+ hours_zero: "{{value}} ساعة",
+ hours_one: "ساعة واحدة",
+ hours_two: "ساعتين",
+ hours_few: "{{value}} ساعات",
+ hours_many: "{{value}} ساعة",
+ hours_other: "{{value}} ساعة",
+ minutes: "{{value}} دقيقة",
+ minutes_zero: "{{value}} دقيقة",
+ minutes_one: "دقيقة واحدة",
+ minutes_two: "دقيقتين",
+ minutes_few: "{{value}} دقائق",
+ minutes_many: "{{value}} دقيقة",
+ minutes_other: "{{value}} دقيقة",
+ },
+ timing: {
+ lastUpdated: "آخر تحديث {{relativeTime}}",
+ noSuccessfulUpdate: "لا يوجد تحديث ناجح",
+ resetTimePassed: "مضى وقت إعادة التعيين",
+ resettingNow: "تتم إعادة التعيين الآن",
+ resetsIn: "تتم إعادة التعيين خلال {{duration}}",
+ runsOutNow: "ينفد الآن",
+ runsOutIn: "ينفد خلال {{duration}}",
+ justNow: "الآن",
+ daysAgo: "قبل {{value}} يومًا",
+ daysAgo_zero: "قبل {{value}} يوم",
+ daysAgo_one: "قبل يوم واحد",
+ daysAgo_two: "قبل يومين",
+ daysAgo_few: "قبل {{value}} أيام",
+ daysAgo_many: "قبل {{value}} يومًا",
+ daysAgo_other: "قبل {{value}} يوم",
+ hoursAgo: "قبل {{value}} ساعة",
+ hoursAgo_zero: "قبل {{value}} ساعة",
+ hoursAgo_one: "قبل ساعة واحدة",
+ hoursAgo_two: "قبل ساعتين",
+ hoursAgo_few: "قبل {{value}} ساعات",
+ hoursAgo_many: "قبل {{value}} ساعة",
+ hoursAgo_other: "قبل {{value}} ساعة",
+ minutesAgo: "قبل {{value}} دقيقة",
+ minutesAgo_zero: "قبل {{value}} دقيقة",
+ minutesAgo_one: "قبل دقيقة واحدة",
+ minutesAgo_two: "قبل دقيقتين",
+ minutesAgo_few: "قبل {{value}} دقائق",
+ minutesAgo_many: "قبل {{value}} دقيقة",
+ minutesAgo_other: "قبل {{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 9bb0460036..df99213d53 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -2070,6 +2070,70 @@ export const en = {
saveAccessibility: "Save review comment",
},
},
+ providerUsage: {
+ options: "Settings",
+ refreshFailed: "Unable to refresh usage",
+ noHosts: "No connected hosts",
+ agentError: "Unable to load usage: {{reason}}",
+ pin: "Pin",
+ unpin: "Unpin",
+ displayAs: "Percentages",
+ displayUsed: "Used",
+ displayRemaining: "Remaining",
+ showInSidebar: "Summary in sidebar",
+ showInSidebarHint: "Pinned windows show in the sidebar footer",
+ runToRefresh: "Run {{command}} to refresh it.",
+ signInAgain: "Sign in again.",
+ loginRejected: "Login rejected (HTTP {{status}}). {{remedy}}",
+ loginExpired: "Login expired {{relativeTime}}. {{remedy}}",
+ title: "Plan usage",
+ refresh: "Refresh",
+ refreshAll: "Refresh all",
+ refreshing: "Refreshing...",
+ loading: "Loading usage...",
+ empty: "No usage data",
+ errorTitle: "Unable to load usage",
+ hostUnavailable: "Connect to {{host}} to see usage",
+ hostUpgradeRequired: "Update {{host}} to see 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",
+ },
+ labels: {
+ session: "Session",
+ weekly: "Weekly",
+ monthly: "Monthly",
+ codeReview: "Code review",
+ credits: "Credits",
+ monthlyCredits: "Monthly credits",
+ },
+ duration: {
+ days: "{{value}}d",
+ hours: "{{value}}h",
+ minutes: "{{value}}m",
+ },
+ timing: {
+ lastUpdated: "Last updated {{relativeTime}}",
+ noSuccessfulUpdate: "No successful update",
+ resetTimePassed: "Reset time passed",
+ 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...",
@@ -2971,4 +3035,18 @@ type WidenStringLeaves = {
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves;
};
-export type TranslationResources = WidenStringLeaves;
+type TranslationResourcesBase = WidenStringLeaves;
+type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
+type DurationPluralKey = `${"days" | "hours" | "minutes"}_${PluralCategory}`;
+type AgoPluralKey = `${"daysAgo" | "hoursAgo" | "minutesAgo"}_${PluralCategory}`;
+
+// i18next plural categories differ by locale, so locale catalogs may add the
+// explicit v4 suffixes they need while retaining the canonical fallback keys.
+export type TranslationResources = TranslationResourcesBase & {
+ providerUsage: TranslationResourcesBase["providerUsage"] & {
+ duration: TranslationResourcesBase["providerUsage"]["duration"] &
+ Partial>;
+ timing: TranslationResourcesBase["providerUsage"]["timing"] &
+ Partial>;
+ };
+};
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 9ed1e85572..ca5601ed03 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -2093,6 +2093,70 @@ export const es: TranslationResources = {
saveAccessibility: "Guardar comentario de revisión",
},
},
+ providerUsage: {
+ options: "Configuración",
+ refreshFailed: "No se pudo actualizar el uso",
+ noHosts: "No hay hosts conectados",
+ agentError: "No se pudo cargar el uso: {{reason}}",
+ pin: "Fijar",
+ unpin: "Desfijar",
+ displayAs: "Porcentajes",
+ displayUsed: "Usado",
+ displayRemaining: "Restante",
+ showInSidebar: "Resumen en la barra lateral",
+ showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral",
+ runToRefresh: "Ejecuta {{command}} para actualizar.",
+ signInAgain: "Vuelve a iniciar sesión.",
+ loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}",
+ loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
+ title: "Uso del plan",
+ refresh: "Actualizar",
+ refreshAll: "Actualizar todo",
+ refreshing: "Actualizando...",
+ loading: "Cargando uso...",
+ empty: "No hay datos de uso",
+ errorTitle: "No se pudo cargar el uso",
+ hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor",
+ hostUpgradeRequired: "Actualiza {{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}}",
+ },
+ labels: {
+ session: "Sesión",
+ weekly: "Semanal",
+ monthly: "Mensual",
+ codeReview: "Revisión de código",
+ credits: "Créditos",
+ monthlyCredits: "Créditos mensuales",
+ },
+ duration: {
+ days: "{{value}} d",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ lastUpdated: "Última actualización {{relativeTime}}",
+ noSuccessfulUpdate: "No hay actualizaciones correctas",
+ resetTimePassed: "Pasó la hora de restablecimiento",
+ 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 6563b1100e..7aaeb79b61 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -2092,6 +2092,70 @@ export const fr: TranslationResources = {
saveAccessibility: "Enregistrer le commentaire de revue",
},
},
+ providerUsage: {
+ options: "Paramètres",
+ refreshFailed: "Impossible d’actualiser l’utilisation",
+ noHosts: "Aucun hôte connecté",
+ agentError: "Impossible de charger l’utilisation : {{reason}}",
+ pin: "Épingler",
+ unpin: "Désépingler",
+ displayAs: "Pourcentages",
+ displayUsed: "Utilisé",
+ displayRemaining: "Restant",
+ showInSidebar: "Résumé dans la barre latérale",
+ showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale",
+ runToRefresh: "Exécutez {{command}} pour actualiser.",
+ signInAgain: "Reconnectez-vous.",
+ loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}",
+ loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
+ title: "Utilisation du forfait",
+ refresh: "Actualiser",
+ refreshAll: "Tout actualiser",
+ refreshing: "Actualisation...",
+ loading: "Chargement de l’utilisation...",
+ empty: "Aucune donnée d’utilisation",
+ errorTitle: "Impossible de charger l’utilisation",
+ hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur",
+ hostUpgradeRequired: "Mettez à jour {{host}} 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}}",
+ },
+ labels: {
+ session: "Session",
+ weekly: "Hebdomadaire",
+ monthly: "Mensuel",
+ codeReview: "Révision de code",
+ credits: "Crédits",
+ monthlyCredits: "Crédits mensuels",
+ },
+ duration: {
+ days: "{{value}} j",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ lastUpdated: "Dernière mise à jour {{relativeTime}}",
+ noSuccessfulUpdate: "Aucune mise à jour réussie",
+ resetTimePassed: "Heure de réinitialisation dépassée",
+ 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 eb22dd663a..184460942c 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -2066,6 +2066,70 @@ export const ja: TranslationResources = {
saveAccessibility: "レビューコメントを保存",
},
},
+ providerUsage: {
+ options: "設定",
+ refreshFailed: "使用状況を更新できません",
+ noHosts: "接続中のホストがありません",
+ agentError: "使用状況を読み込めません: {{reason}}",
+ pin: "ピン留め",
+ unpin: "ピン留めを解除",
+ displayAs: "割合",
+ displayUsed: "使用済み",
+ displayRemaining: "残り",
+ showInSidebar: "サイドバーに概要を表示",
+ showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します",
+ runToRefresh: "{{command}} を実行して更新してください。",
+ signInAgain: "再度サインインしてください。",
+ loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}",
+ loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
+ title: "プラン使用量",
+ refresh: "更新",
+ refreshAll: "すべて更新",
+ refreshing: "更新中...",
+ loading: "使用状況を読み込み中...",
+ empty: "使用状況データがありません",
+ errorTitle: "使用状況を読み込めません",
+ hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
+ hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
+ clientUnavailable: "ホスト接続の準備ができていません",
+ retry: "再試行",
+ tooltipLoading: "プラン使用状況を読み込み中...",
+ status: {
+ error: "エラー",
+ unavailable: "利用不可",
+ },
+ values: {
+ used: "{{percentage}} 使用済み",
+ remaining: "残り {{amount}}",
+ },
+ labels: {
+ session: "セッション",
+ weekly: "週間",
+ monthly: "月間",
+ codeReview: "コードレビュー",
+ credits: "クレジット",
+ monthlyCredits: "月間クレジット",
+ },
+ duration: {
+ days: "{{value}}日",
+ hours: "{{value}}時間",
+ minutes: "{{value}}分",
+ },
+ timing: {
+ lastUpdated: "最終更新: {{relativeTime}}",
+ noSuccessfulUpdate: "正常な更新はまだありません",
+ resetTimePassed: "リセット時刻を過ぎています",
+ 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/ko.ts b/packages/app/src/i18n/resources/ko.ts
index afdb8974e9..03238eb8dc 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -2055,6 +2055,70 @@ export const ko: TranslationResources = {
saveAccessibility: "리뷰 댓글 저장",
},
},
+ providerUsage: {
+ options: "설정",
+ refreshFailed: "사용량을 새로 고칠 수 없습니다",
+ noHosts: "연결된 호스트가 없습니다",
+ agentError: "사용량을 불러올 수 없습니다: {{reason}}",
+ pin: "고정",
+ unpin: "고정 해제",
+ displayAs: "백분율",
+ displayUsed: "사용됨",
+ displayRemaining: "남음",
+ showInSidebar: "사이드바에 요약 표시",
+ showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다",
+ runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.",
+ signInAgain: "다시 로그인하세요.",
+ loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}",
+ loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
+ title: "플랜 사용량",
+ refresh: "새로 고침",
+ refreshAll: "모두 새로 고침",
+ refreshing: "새로 고치는 중...",
+ loading: "사용량 불러오는 중...",
+ empty: "사용량 데이터가 없습니다",
+ errorTitle: "사용량을 불러올 수 없습니다",
+ hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
+ hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
+ clientUnavailable: "호스트 연결이 준비되지 않았습니다",
+ retry: "다시 시도",
+ tooltipLoading: "플랜 사용량 불러오는 중...",
+ status: {
+ error: "오류",
+ unavailable: "사용할 수 없음",
+ },
+ values: {
+ used: "{{percentage}} 사용됨",
+ remaining: "{{amount}} 남음",
+ },
+ labels: {
+ session: "세션",
+ weekly: "주간",
+ monthly: "월간",
+ codeReview: "코드 검토",
+ credits: "크레딧",
+ monthlyCredits: "월간 크레딧",
+ },
+ duration: {
+ days: "{{value}}일",
+ hours: "{{value}}시간",
+ minutes: "{{value}}분",
+ },
+ timing: {
+ lastUpdated: "마지막 업데이트: {{relativeTime}}",
+ noSuccessfulUpdate: "성공한 업데이트가 없습니다",
+ resetTimePassed: "초기화 시간이 지났습니다",
+ 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 f89fb97bf0..f2d5be4653 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -2078,6 +2078,70 @@ export const ptBR: TranslationResources = {
saveAccessibility: "Salvar comentário de revisão",
},
},
+ providerUsage: {
+ options: "Configurações",
+ refreshFailed: "Não foi possível atualizar o uso",
+ noHosts: "Nenhum host conectado",
+ agentError: "Não foi possível carregar o uso: {{reason}}",
+ pin: "Fixar",
+ unpin: "Desafixar",
+ displayAs: "Porcentagens",
+ displayUsed: "Usado",
+ displayRemaining: "Restante",
+ showInSidebar: "Resumo na barra lateral",
+ showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral",
+ runToRefresh: "Execute {{command}} para atualizar.",
+ signInAgain: "Entre novamente.",
+ loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}",
+ loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
+ title: "Uso do plano",
+ refresh: "Atualizar",
+ refreshAll: "Atualizar tudo",
+ refreshing: "Atualizando...",
+ loading: "Carregando uso...",
+ empty: "Sem dados de uso",
+ errorTitle: "Não foi possível carregar o uso",
+ hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor",
+ hostUpgradeRequired: "Atualize {{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",
+ },
+ labels: {
+ session: "Sessão",
+ weekly: "Semanal",
+ monthly: "Mensal",
+ codeReview: "Revisão de código",
+ credits: "Créditos",
+ monthlyCredits: "Créditos mensais",
+ },
+ duration: {
+ days: "{{value}} d",
+ hours: "{{value}} h",
+ minutes: "{{value}} min",
+ },
+ timing: {
+ lastUpdated: "Última atualização {{relativeTime}}",
+ noSuccessfulUpdate: "Nenhuma atualização bem-sucedida",
+ resetTimePassed: "O horário de redefinição passou",
+ 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 a1a70822b4..0778a34f38 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -2078,6 +2078,70 @@ export const ru: TranslationResources = {
saveAccessibility: "Сохранить комментарий к ревью",
},
},
+ providerUsage: {
+ options: "Настройки",
+ refreshFailed: "Не удалось обновить использование",
+ noHosts: "Нет подключённых хостов",
+ agentError: "Не удалось загрузить использование: {{reason}}",
+ pin: "Закрепить",
+ unpin: "Открепить",
+ displayAs: "Проценты",
+ displayUsed: "Использовано",
+ displayRemaining: "Осталось",
+ showInSidebar: "Сводка на боковой панели",
+ showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели",
+ runToRefresh: "Выполните {{command}} для обновления.",
+ signInAgain: "Войдите снова.",
+ loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}",
+ loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
+ title: "Использование плана",
+ refresh: "Обновить",
+ refreshAll: "Обновить всё",
+ refreshing: "Обновление...",
+ loading: "Загрузка данных об использовании...",
+ empty: "Нет данных об использовании",
+ errorTitle: "Не удалось загрузить данные об использовании",
+ hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
+ hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
+ clientUnavailable: "Соединение с хостом не готово",
+ retry: "Повторить",
+ tooltipLoading: "Загрузка использования плана...",
+ status: {
+ error: "Ошибка",
+ unavailable: "Недоступно",
+ },
+ values: {
+ used: "Использовано: {{percentage}}",
+ remaining: "Осталось: {{amount}}",
+ },
+ labels: {
+ session: "Сессия",
+ weekly: "Еженедельно",
+ monthly: "Ежемесячно",
+ codeReview: "Проверка кода",
+ credits: "Кредиты",
+ monthlyCredits: "Ежемесячные кредиты",
+ },
+ duration: {
+ days: "{{value}} дн.",
+ hours: "{{value}} ч",
+ minutes: "{{value}} мин",
+ },
+ timing: {
+ lastUpdated: "Последнее обновление {{relativeTime}}",
+ noSuccessfulUpdate: "Успешных обновлений нет",
+ resetTimePassed: "Время сброса прошло",
+ 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 ca221d7d3a..38106dfd93 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -2023,6 +2023,70 @@ export const zhCN: TranslationResources = {
saveAccessibility: "保存 review 评论",
},
},
+ providerUsage: {
+ options: "设置",
+ refreshFailed: "无法刷新用量",
+ noHosts: "没有已连接的主机",
+ agentError: "无法加载用量:{{reason}}",
+ pin: "固定",
+ unpin: "取消固定",
+ displayAs: "百分比",
+ displayUsed: "已使用",
+ displayRemaining: "剩余",
+ showInSidebar: "在侧边栏显示摘要",
+ showInSidebarHint: "固定的时间窗口显示在侧边栏底部",
+ runToRefresh: "运行 {{command}} 以刷新。",
+ signInAgain: "请重新登录。",
+ loginRejected: "登录被拒绝(HTTP {{status}})。{{remedy}}",
+ loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}",
+ title: "套餐用量",
+ refresh: "刷新",
+ refreshAll: "全部刷新",
+ refreshing: "刷新中...",
+ loading: "正在加载用量...",
+ empty: "暂无用量数据",
+ errorTitle: "无法加载用量",
+ hostUnavailable: "连接到 {{host}} 以查看提供商用量",
+ hostUpgradeRequired: "更新 {{host}} 以查看提供商用量",
+ clientUnavailable: "主机连接尚未就绪",
+ retry: "重试",
+ tooltipLoading: "正在加载套餐用量...",
+ status: {
+ error: "错误",
+ unavailable: "不可用",
+ },
+ values: {
+ used: "已使用 {{percentage}}",
+ remaining: "剩余 {{amount}}",
+ },
+ labels: {
+ session: "会话",
+ weekly: "每周",
+ monthly: "每月",
+ codeReview: "代码审查",
+ credits: "积分",
+ monthlyCredits: "每月积分",
+ },
+ duration: {
+ days: "{{value}}天",
+ hours: "{{value}}小时",
+ minutes: "{{value}}分钟",
+ },
+ timing: {
+ lastUpdated: "上次更新于 {{relativeTime}}",
+ noSuccessfulUpdate: "尚无成功更新",
+ resetTimePassed: "重置时间已过",
+ 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/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx
index 0199c08ff2..95f39d1513 100644
--- a/packages/app/src/usage/agent-usage.tsx
+++ b/packages/app/src/usage/agent-usage.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
@@ -21,6 +22,7 @@ export function AgentUsage({
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
+ useTranslation();
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx
index 38b2f0804a..a632d03972 100644
--- a/packages/app/src/usage/balance-bar.tsx
+++ b/packages/app/src/usage/balance-bar.tsx
@@ -1,16 +1,26 @@
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";
-import { clampPct, formatAmount, formatResetLabel } from "./format";
+import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
+import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
-function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
+function resolveBalance({
+ balance,
+ t,
+ locale,
+}: {
+ 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 +30,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 };
@@ -48,8 +61,9 @@ export function UsageBalanceBar({
balance: UsageBalance;
retained?: boolean;
}) {
- const { i18n } = useTranslation();
- const { amountText, usedPct } = resolveBalance(balance, i18n.language);
+ const { t, i18n } = useTranslation();
+ useRelativeTimeTick({ enabled: balance.resetsAt != null });
+ const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained);
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
- {balance.label}
-
-
- {amountText}
- {resetLabel ? {` · ${resetLabel}`} : null}
+ {formatProviderUsageLabel(balance.id, balance.label)}
+
+
+ {amountText}
+
+ {resetLabel ? (
+
+ {resetLabel}
+
+ ) : null}
+
{usedPct != null ? (
@@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
+ flexShrink: 1,
+ alignItems: "flex-end",
+ },
+ amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
+ textAlign: "right",
},
track: {
height: 4,
diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx
index 562f3805a1..da93c67ee3 100644
--- a/packages/app/src/usage/card.tsx
+++ b/packages/app/src/usage/card.tsx
@@ -1,5 +1,6 @@
import { RotateCw } from "lucide-react-native";
import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -12,19 +13,23 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
-import { useCompactTimeAgo } from "@/hooks/use-time-ago";
+import { i18n } from "@/i18n/i18next";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
-import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
+import { formatAgo } from "./format";
+import { resolveUsageReport, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
import { UsageWindowBar } from "./window-bar";
+import { useRelativeTimeTick } from "./use-relative-time-tick";
function statusText(report: UsageReport): string | null {
if (report.status === "available") return null;
- return report.status === "error" ? "Error" : "Unavailable";
+ return report.status === "error"
+ ? i18n.t("providerUsage.status.error")
+ : i18n.t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -49,6 +54,7 @@ export function UsageCard({
refreshable: boolean;
compact?: boolean;
}) {
+ useTranslation();
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
@@ -257,10 +263,17 @@ function UsageFreshness({
style: StyleProp;
testID: string;
}) {
- const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
+ const { t } = useTranslation();
+ useRelativeTimeTick({ enabled: successfulAt != null });
+ const relativeTime = formatAgo(successfulAt);
+ const freshness = relativeTime
+ ? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", {
+ relativeTime,
+ })
+ : t("providerUsage.timing.noSuccessfulUpdate");
return (
- {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
+ {freshness}
);
}
diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts
index 639272283b..9482a85dce 100644
--- a/packages/app/src/usage/copy.ts
+++ b/packages/app/src/usage/copy.ts
@@ -1,5 +1,6 @@
import type { UsageProblem } from "@getpaseo/protocol/messages";
-import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
+import { i18n } from "@/i18n/i18next";
+import { formatAgo } from "./format";
// User-facing copy for the usage surfaces, kept in one file so localization is a
// single-file change.
@@ -7,34 +8,76 @@ export const usageCopy = {
problem: (problem: UsageProblem, now: Date = new Date()): string => {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
- ? `Run ${problem.refreshedBy} to refresh it.`
- : "Sign in again.";
- if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
- const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
- return `Login expired ${ago}. ${remedy}`;
+ ? i18n.t("providerUsage.runToRefresh", { command: problem.refreshedBy })
+ : i18n.t("providerUsage.signInAgain");
+ if (problem.kind === "rejected")
+ return i18n.t("providerUsage.loginRejected", { status: problem.status, remedy });
+ return i18n.t("providerUsage.loginExpired", {
+ relativeTime: formatAgo(problem.expiresAt, now.getTime()),
+ remedy,
+ });
+ },
+ get title() {
+ return i18n.t("providerUsage.title");
+ },
+ get planUsage() {
+ return i18n.t("providerUsage.title");
+ },
+ get options() {
+ return i18n.t("providerUsage.options");
+ },
+ get refresh() {
+ return i18n.t("providerUsage.refresh");
+ },
+ get refreshAll() {
+ return i18n.t("providerUsage.refreshAll");
+ },
+ get refreshing() {
+ return i18n.t("providerUsage.refreshing");
+ },
+ get refreshFailed() {
+ return i18n.t("providerUsage.refreshFailed");
+ },
+ get loading() {
+ return i18n.t("providerUsage.loading");
+ },
+ get empty() {
+ return i18n.t("providerUsage.empty");
+ },
+ get noHosts() {
+ return i18n.t("providerUsage.noHosts");
+ },
+ get errorTitle() {
+ return i18n.t("providerUsage.errorTitle");
+ },
+ agentError: (reason: string) => i18n.t("providerUsage.agentError", { reason }),
+ hostUnavailable: (host: string) => i18n.t("providerUsage.hostUnavailable", { host }),
+ hostUpgradeRequired: (host: string) => i18n.t("providerUsage.hostUpgradeRequired", { host }),
+ get clientUnavailable() {
+ return i18n.t("providerUsage.clientUnavailable");
+ },
+ get retry() {
+ return i18n.t("providerUsage.retry");
+ },
+ get pin() {
+ return i18n.t("providerUsage.pin");
+ },
+ get unpin() {
+ return i18n.t("providerUsage.unpin");
+ },
+ get displayAs() {
+ return i18n.t("providerUsage.displayAs");
+ },
+ get displayUsed() {
+ return i18n.t("providerUsage.displayUsed");
+ },
+ get displayRemaining() {
+ return i18n.t("providerUsage.displayRemaining");
+ },
+ get showInSidebar() {
+ return i18n.t("providerUsage.showInSidebar");
+ },
+ get showInSidebarHint() {
+ return i18n.t("providerUsage.showInSidebarHint");
},
- title: "Usage",
- planUsage: "Plan usage",
- options: "Settings",
- refresh: "Refresh",
- refreshAll: "Refresh all",
- refreshing: "Refreshing...",
- refreshFailed: "Unable to refresh usage",
- updated: "Updated",
- loading: "Loading usage...",
- empty: "No usage data",
- noHosts: "No connected hosts",
- errorTitle: "Unable to load usage",
- agentError: (reason: string) => `Unable to load usage: ${reason}`,
- hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
- hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
- clientUnavailable: "Host connection is not ready",
- retry: "Try again",
- pin: "Pin",
- unpin: "Unpin",
- displayAs: "Percentages",
- displayUsed: "Used",
- displayRemaining: "Remaining",
- showInSidebar: "Summary in sidebar",
- showInSidebarHint: "Pinned windows show in the sidebar footer",
} as const;
diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts
index 22529c8d71..8fed67dff2 100644
--- a/packages/app/src/usage/format.test.ts
+++ b/packages/app/src/usage/format.test.ts
@@ -1,7 +1,26 @@
-import { describe, expect, it } from "vitest";
-import { formatAmount, formatResetLabel } from "./format";
+import { usageCopy } from "./copy";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { i18n } from "@/i18n/i18next";
+import {
+ formatDisplayPct,
+ formatAgo,
+ formatAmount,
+ formatPct,
+ formatProviderUsageLabel,
+ 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,6 +31,68 @@ 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, false, NOW)).toBe("resets 2h");
+ expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
+
+ await i18n.changeLanguage("ja");
+ expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
+ expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達");
+ expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前");
+ });
+
+ it("formats percentages with locale-specific spacing", () => {
+ expect(formatPct(42, "fr")).toBe("42\u00a0%");
+ });
+
+ it("formats relative-time counts with the active locale", async () => {
+ await i18n.changeLanguage("fr");
+ const days = 1_234;
+ const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString();
+ const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
+
+ expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j");
+ expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j");
+ expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j");
+ });
+
+ it("selects Arabic plural forms for relative durations", async () => {
+ await i18n.changeLanguage("ar");
+ const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
+ const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString();
+ const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString();
+
+ expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين");
+ expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات");
+ expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين");
+ });
+
+ it("uses locale-aware compact notation for token balances", () => {
+ expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K");
+ expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف");
+ });
+
+ it("localizes known provider-usage labels and preserves provider-specific labels", async () => {
+ await i18n.changeLanguage("ko");
+
+ expect(formatProviderUsageLabel("session", "Session")).toBe("세션");
+ expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션");
+ expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit");
+ expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간");
+ expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe(
+ "코드 검토 · 주간",
+ );
+ expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe(
+ "코드 검토 · 5-hour",
+ );
+ expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧");
+ expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit");
+ });
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
@@ -20,3 +101,33 @@ it("retained reset times that have passed do not claim the quota is resetting",
expect(formatResetLabel(past)).toBe("resetting now");
expect(formatResetLabel(null, true)).toBeNull();
});
+
+it("says that a usage window percentage is consumed", () => {
+ expect(formatDisplayPct(70, "used")).toBe("70% used");
+ expect(formatDisplayPct(30, "remaining")).toBe("30% left");
+});
+it("translates the usage refresh control into Japanese", async () => {
+ try {
+ await i18n.changeLanguage("ja");
+ expect(usageCopy.refresh).toBe("更新");
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+});
+
+it("translates the neighboring usage percentage settings", async () => {
+ try {
+ await i18n.changeLanguage("ja");
+ expect(usageCopy.displayUsed).toBe("使用済み");
+ expect(usageCopy.displayRemaining).toBe("残り");
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+});
+
+it("preserves provider-defined labels even when their IDs match a standard period", () => {
+ expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment");
+ expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe(
+ "Custom review allowance",
+ );
+});
diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts
index e13a550e7b..f502abfe69 100644
--- a/packages/app/src/usage/format.ts
+++ b/packages/app/src/usage/format.ts
@@ -1,47 +1,155 @@
-import { formatTokenCount } from "@/components/context-window-meter.utils";
+import { i18n } from "@/i18n/i18next";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
+const providerUsageLabels = {
+ session: { label: "Session", key: "providerUsage.labels.session" },
+ weekly: { label: "Weekly", key: "providerUsage.labels.weekly" },
+ monthly: { label: "Monthly", key: "providerUsage.labels.monthly" },
+ code_review: { label: "Code review", key: "providerUsage.labels.codeReview" },
+ credits: { label: "Credits", key: "providerUsage.labels.credits" },
+ monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
+} as const;
+
+export function formatProviderUsageLabel(id: string, label: string): string {
+ const reviewPrefix = "Code review · ";
+ if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
+ const periodId = id.slice("code_review:".length);
+ const periodLabel = label.slice(reviewPrefix.length);
+ return `${i18n.t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel)}`;
+ }
+ const standard =
+ providerUsageLabels[id as keyof typeof providerUsageLabels] ??
+ (id === "five_hour" ? providerUsageLabels.session : undefined);
+ return standard?.label === label ? i18n.t(standard.key) : label;
+}
+
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 {
+ const resolvedLocale = locale ?? i18n.resolvedLanguage;
+ return new Intl.NumberFormat(resolvedLocale, {
+ 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, retained = false): 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,
+ retained = false,
+ now = Date.now(),
+): string | null {
if (!iso) return null;
- const rel = relativeDuration(iso);
+ const rel = relativeDuration(iso, now);
if (!rel) return null;
- if (rel === "now" && retained) return "Reset time passed";
- return rel === "now" ? "resetting now" : `resets ${rel}`;
+ if (rel.unit === "now" && retained) return i18n.t("providerUsage.timing.resetTimePassed");
+ 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/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx
index 927d32e7d8..23e56d1d57 100644
--- a/packages/app/src/usage/host-usage-section.tsx
+++ b/packages/app/src/usage/host-usage-section.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
@@ -5,6 +6,7 @@ import { UsageSection } from "./usage-section";
/** A host's usage reports, for its settings page. */
export function HostUsageSection({ serverId }: { serverId: string }) {
+ useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
return (
diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts
index 13da42a6eb..a84b9da098 100644
--- a/packages/app/src/usage/model.test.ts
+++ b/packages/app/src/usage/model.test.ts
@@ -1,8 +1,8 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
+import { i18n } from "@/i18n/i18next";
import {
resolveUsageReport,
displayPercent,
- formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
@@ -65,6 +65,18 @@ describe("resolveUsageView", () => {
).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" });
});
+ it("localizes unavailable host messages while retaining the host name", async () => {
+ await i18n.changeLanguage("ko");
+ expect(
+ resolveUsageView({
+ hostLabel: "Laptop",
+ isConnected: false,
+ supportsUsage: false,
+ query: undefined,
+ }),
+ ).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" });
+ });
+
it("moves from loading to ready to error", () => {
const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true };
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
@@ -80,6 +92,10 @@ describe("resolveUsageView", () => {
});
});
+afterEach(async () => {
+ await i18n.changeLanguage("en");
+});
+
const hosts: UsageHost[] = [
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
@@ -185,14 +201,6 @@ describe("displayPercent", () => {
});
});
-describe("formatUsageFreshness", () => {
- it("says when the report was fetched", () => {
- expect(formatUsageFreshness("now")).toBe("Updated just now");
- expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
- expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
- });
-});
-
describe("resolveUsageRefresh", () => {
it("is pending while a refresh runs, even after an earlier failure", () => {
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
index 1f1c82cce3..df4250a889 100644
--- a/packages/app/src/usage/model.ts
+++ b/packages/app/src/usage/model.ts
@@ -1,4 +1,3 @@
-import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -31,11 +30,6 @@ export function usageWindowRowLabel(input: {
return `${input.pinLabel}, ${summary}`;
}
-/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
-export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
- return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
-}
-
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
export type UsageRefresh = "idle" | "pending" | "failed";
diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx
index 886c21e190..9e61aa8d6c 100644
--- a/packages/app/src/usage/options.tsx
+++ b/packages/app/src/usage/options.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
@@ -13,15 +14,15 @@ import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
-const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [
- { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
- { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
-];
-
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
+ useTranslation();
+ const displayAsOptions: SegmentedControlOption[] = [
+ { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
+ { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
+ ];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
@@ -55,7 +56,7 @@ export function UsageOptions() {
void }) {
+ useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx
index 8305c3f878..88685729c3 100644
--- a/packages/app/src/usage/usage-modal.tsx
+++ b/packages/app/src/usage/usage-modal.tsx
@@ -22,6 +22,7 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
+ useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts
new file mode 100644
index 0000000000..2e59bd9ddc
--- /dev/null
+++ b/packages/app/src/usage/use-relative-time-tick.ts
@@ -0,0 +1,12 @@
+import { useEffect, useReducer } from "react";
+import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
+
+/** Re-renders a mounted usage label when its relative time can change. */
+export function useRelativeTimeTick({ enabled }: { enabled: boolean }): void {
+ const [, tick] = useReducer((value: number) => value + 1, 0);
+
+ useEffect(() => {
+ if (!enabled) return undefined;
+ return subscribeToRelativeTimeTick("minute", tick);
+ }, [enabled]);
+}
diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx
index 79ea6b9f2d..8ecf3d045d 100644
--- a/packages/app/src/usage/window-bar.tsx
+++ b/packages/app/src/usage/window-bar.tsx
@@ -1,15 +1,22 @@
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";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
+import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
@@ -39,6 +46,7 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
+ const { i18n } = useTranslation();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
@@ -46,14 +54,16 @@ export function UsageWindowBar({
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > Date.now());
+ useRelativeTimeTick({ enabled: (isAtRisk ? window.runsOutAt : window.resetsAt) != null });
const trailing = isAtRisk
- ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
+ ? formatRunsOutLabel(window.runsOutAt)
: formatResetLabel(window.resetsAt, retained);
- const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
+ const value =
+ shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
- label: window.label,
+ label: formatProviderUsageLabel(window.id, window.label),
value,
trailing,
isAtRisk,
@@ -124,7 +134,7 @@ function WindowRowContent({
{label}
-
+
{value}
{trailing ? (
{` · ${trailing}`}
@@ -209,6 +219,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,