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..b680754da2 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();
}
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
+ await page.clock.install();
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
@@ -615,7 +617,8 @@ 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 advanceUsageClock(page, 60_000);
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -640,8 +643,9 @@ 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 advanceUsageClock(page, 60_000);
+ await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
@@ -651,7 +655,88 @@ 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, summary: true }],
+ });
+ try {
+ await gotoAppShell(page);
+ await openUsageFromIcon(page);
+ await expect(page.getByText("70% used", { exact: true })).toBeVisible();
+ await switchToJapanese(page);
+ await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
+ 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();
+ }
+});
+
+test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
+ test.setTimeout(120_000);
+ const now = new Date();
+ const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
+ const login = await installLoginUsage({
+ status: "available",
+ windows: [
+ { id: "session", label: "Session", usedPct: 42, resetsAt: reset },
+ { id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
+ ],
+ balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
+ });
+ try {
+ await openCompactUsageWithClock(page, now);
+ const card = page.getByTestId("usage-report-login-journey:account");
+ await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
+ await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
+ // The shared minute clock is wall-aligned, and the daemon timestamps use real time.
+ // After two browser minutes the latest displayed tick is one or two minutes old.
+ await advanceUsageClock(page, 120_000);
+ await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
+ await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
+ await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
+ await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
+ /^resets (28|29)m$/,
+ );
+ } finally {
+ await login.cleanup();
+ }
+});
+
+async function openCompactUsageWithClock(page: Page, now: Date) {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.clock.install({ time: now });
+ await gotoAppShell(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
+}
+
+async function advanceUsageClock(page: Page, milliseconds: number) {
+ await page.clock.fastForward(milliseconds);
+}
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 b7b81c66ee..27be1cd3ea 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -2046,6 +2046,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 d8a4081dda..c81039affa 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -2071,6 +2071,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...",
@@ -2973,4 +3037,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 7fba1c22b9..03511d299c 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -2094,6 +2094,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 54b6de6098..e581139dd0 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -2094,6 +2094,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 31f1d4241f..07993a6b2c 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -2067,6 +2067,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 1214aa2435..00e18ad1e3 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -2056,6 +2056,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 1a24fd1f83..6303dd2c46 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -2079,6 +2079,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 3b444895e5..fb58a4db29 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -2079,6 +2079,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 7b517fafb1..7e7e2382c6 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -2024,6 +2024,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..bf10054db6 100644
--- a/packages/app/src/usage/agent-usage.tsx
+++ b/packages/app/src/usage/agent-usage.tsx
@@ -1,7 +1,7 @@
+import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
-import { usageCopy } from "./copy";
import { useUsageDisplay } from "./display";
import { useAgentUsage } from "./queries";
import type { UsageReportEntry } from "./types";
@@ -21,6 +21,7 @@ export function AgentUsage({
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
+ const { t } = useTranslation();
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
@@ -35,7 +36,7 @@ export function AgentUsage({
/>
) : (
- {view.kind === "loading" ? usageCopy.loading : view.message}
+ {view.kind === "loading" ? t("providerUsage.loading") : view.message}
)}
>
diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx
index 38b2f0804a..def723c554 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,10 +61,11 @@ export function UsageBalanceBar({
balance: UsageBalance;
retained?: boolean;
}) {
- const { i18n } = useTranslation();
- const { amountText, usedPct } = resolveBalance(balance, i18n.language);
+ const { t, i18n } = useTranslation();
+ const now = 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);
+ const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const fillStyle = useMemo>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
- {balance.label}
-
-
- {amountText}
- {resetLabel ? {` · ${resetLabel}`} : null}
+ {formatProviderUsageLabel(balance.id, balance.label, t)}
+
+
+ {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/bars.browser.test.tsx b/packages/app/src/usage/bars.browser.test.tsx
new file mode 100644
index 0000000000..7b978f7ced
--- /dev/null
+++ b/packages/app/src/usage/bars.browser.test.tsx
@@ -0,0 +1,53 @@
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { i18n } from "@/i18n/i18next";
+import { UsageWindowBar } from "./window-bar";
+import type { UsageBalance, UsageWindow } from "./types";
+import { UsageBalanceBar } from "./balance-bar";
+
+const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 };
+const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 };
+const ignorePin = () => {};
+
+let root: Root;
+let container: HTMLDivElement;
+beforeEach(async () => {
+ vi.stubGlobal("React", React);
+ await i18n.changeLanguage("en");
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ act(() => root.unmount());
+ container.remove();
+ await i18n.changeLanguage("en");
+ vi.unstubAllGlobals();
+});
+
+it("translates mounted usage rows when the language changes", async () => {
+ act(() =>
+ root.render(
+ <>
+
+
+ >,
+ ),
+ );
+ expect(container.textContent).toContain("Session70% used");
+ expect(container.textContent).toContain("Credits");
+ await act(async () => {
+ await i18n.changeLanguage("ja");
+ });
+ expect(container.textContent).toContain("セッション70% 使用済み");
+ expect(container.textContent).toContain("クレジット");
+});
diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx
index 562f3805a1..7d0f9903da 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,22 @@ 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 type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
-import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
-import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
+import { formatAgo, formatProviderUsageLabel } 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 {
+function statusText(report: UsageReport, t: TFunction): string | null {
if (report.status === "available") return null;
- return report.status === "error" ? "Error" : "Unavailable";
+ return report.status === "error"
+ ? t("providerUsage.status.error")
+ : t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -49,15 +53,16 @@ export function UsageCard({
refreshable: boolean;
compact?: boolean;
}) {
+ const { t } = 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.
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
- const status = statusText(usage);
+ const status = statusText(usage, t);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
- resolveUsageReport(entry);
+ resolveUsageReport(entry, t);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -156,7 +161,7 @@ export function UsageCard({
{refreshState === "failed" ? (
- {usageCopy.refreshFailed}
+ {t("providerUsage.refreshFailed")}
) : null}
@@ -176,6 +181,7 @@ function CardWindowBar({
display: UsageDisplay;
pinnable: boolean;
}) {
+ const { t } = useTranslation();
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
[entry.sourceId, window.id],
@@ -190,7 +196,7 @@ function CardWindowBar({
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
- pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
+ pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
/>
);
@@ -212,6 +218,7 @@ function UsageRefreshButton({
onRefresh: () => void;
compact: boolean;
}) {
+ const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
@@ -227,7 +234,7 @@ function UsageRefreshButton({
);
return (
;
testID: string;
}) {
- const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
+ const { t } = useTranslation();
+ const now = useRelativeTimeTick({ enabled: successfulAt != null });
+ const relativeTime = formatAgo(successfulAt, now, t);
+ 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..14bcfe7d95 100644
--- a/packages/app/src/usage/copy.ts
+++ b/packages/app/src/usage/copy.ts
@@ -1,40 +1,23 @@
+import type { TFunction } from "i18next";
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.
+// Messages for usage failures outside the UI. Rendered copy uses useTranslation.
export const usageCopy = {
- problem: (problem: UsageProblem, now: Date = new Date()): string => {
+ problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): 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}`;
+ ? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
+ : t("providerUsage.signInAgain");
+ if (problem.kind === "rejected")
+ return t("providerUsage.loginRejected", { status: problem.status, remedy });
+ return t("providerUsage.loginExpired", {
+ relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
+ remedy,
+ });
+ },
+ get clientUnavailable() {
+ return i18n.t("providerUsage.clientUnavailable");
},
- 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..b7b81f0e5a 100644
--- a/packages/app/src/usage/format.test.ts
+++ b/packages/app/src/usage/format.test.ts
@@ -1,7 +1,25 @@
-import { describe, expect, it } from "vitest";
-import { formatAmount, formatResetLabel } from "./format";
+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 +30,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 +100,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(i18n.t("providerUsage.refresh")).toBe("更新");
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+});
+
+it("translates the neighboring usage percentage settings", async () => {
+ try {
+ await i18n.changeLanguage("ja");
+ expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
+ expect(i18n.t("providerUsage.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..e7a6ba6f7b 100644
--- a/packages/app/src/usage/format.ts
+++ b/packages/app/src/usage/format.ts
@@ -1,47 +1,163 @@
-import { formatTokenCount } from "@/components/context-window-meter.utils";
+import type { TFunction } from "i18next";
+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, t: TFunction = i18n.t): 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 `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`;
+ }
+ const standard =
+ providerUsageLabels[id as keyof typeof providerUsageLabels] ??
+ (id === "five_hour" ? providerUsageLabels.session : undefined);
+ return standard?.label === label ? 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,
+ t: TFunction = i18n.t,
+): string {
+ const percentage = formatPct(value, locale);
+ return displayAs === "used"
+ ? t("providerUsage.values.used", { percentage })
+ : 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, t: TFunction): string | null {
+ switch (duration.unit) {
+ case "now":
+ return null;
+ case "days":
+ return t("providerUsage.duration.days", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ case "hours":
+ return t("providerUsage.duration.hours", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ case "minutes":
+ return t("providerUsage.duration.minutes", {
+ count: duration.count,
+ value: formatCount(duration.count),
+ });
+ }
+}
+
+export function formatResetLabel(
+ iso: string | null | undefined,
+ retained = false,
+ now = Date.now(),
+ t: TFunction = i18n.t,
+): 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 t("providerUsage.timing.resetTimePassed");
+ if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
+ const duration = formatRelativeDuration(rel, t);
+ return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
+}
+
+export function formatRunsOutLabel(
+ iso: string | null | undefined,
+ now = Date.now(),
+ t: TFunction = i18n.t,
+): string | null {
+ if (!iso) return null;
+ const rel = relativeDuration(iso, now);
+ if (!rel) return null;
+ if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
+ const duration = formatRelativeDuration(rel, t);
+ return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
+}
+
+export function formatAgo(
+ iso: string | null | undefined,
+ now = Date.now(),
+ t: TFunction = i18n.t,
+): string | null {
+ if (!iso) return null;
+ const diffMs = now - new Date(iso).getTime();
+ if (!Number.isFinite(diffMs)) return null;
+ if (diffMs < 60_000) return 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 t("providerUsage.timing.daysAgo", {
+ count: diffDays,
+ value: formatCount(diffDays),
+ });
+ }
+ if (diffHours > 0) {
+ return t("providerUsage.timing.hoursAgo", {
+ count: diffHours,
+ value: formatCount(diffHours),
+ });
+ }
+ return 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..e80e839680 100644
--- a/packages/app/src/usage/host-usage-section.tsx
+++ b/packages/app/src/usage/host-usage-section.tsx
@@ -1,16 +1,17 @@
-import { usageCopy } from "./copy";
+import { useTranslation } from "react-i18next";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
/** A host's usage reports, for its settings page. */
export function HostUsageSection({ serverId }: { serverId: string }) {
+ const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
return (
{
).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");
@@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
report: success.report as Extract,
},
};
- expect(resolveUsageReport(failure)).toEqual({
+ expect(resolveUsageReport(failure, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -347,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: true,
messages: ["Rate limited"],
});
- expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
+ expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
windows: [],
balances: [],
details: [],
@@ -356,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: false,
messages: ["Rate limited"],
});
- expect(resolveUsageReport(success)).toEqual({
+ expect(resolveUsageReport(success, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -384,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", ()
it("available and retained usage show the same successful completion time", () => {
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
if (success.report.status !== "available") throw new Error("fixture must be available");
- expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
+ expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
const failure: UsageReportEntry = {
...success,
successfulAt: undefined,
@@ -392,6 +400,8 @@ it("available and retained usage show the same successful completion time", () =
report: { status: "error", error: "Rate limited" },
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
};
- expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
+ expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe(
+ resolveUsageReport(success, i18n.t).successfulAt,
+ );
expect(upsertReport([failure], success)).toEqual([failure]);
});
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
index 1f1c82cce3..48143ad23f 100644
--- a/packages/app/src/usage/model.ts
+++ b/packages/app/src/usage/model.ts
@@ -1,4 +1,5 @@
-import { formatCompactTimeAgoAsProse } from "@/utils/time";
+import type { TFunction } from "i18next";
+import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -31,11 +32,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";
@@ -108,16 +104,26 @@ export interface UsageQueryState {
isFetching: boolean;
}
-export function resolveUsageView(input: {
- hostLabel: string;
- isConnected: boolean;
- supportsUsage: boolean;
- query: UsageQueryState | undefined;
-}): UsageView {
+export function resolveUsageView(
+ input: {
+ hostLabel: string;
+ isConnected: boolean;
+ supportsUsage: boolean;
+ query: UsageQueryState | undefined;
+ },
+ t: TFunction = i18n.t,
+): UsageView {
const { hostLabel, isConnected, supportsUsage, query } = input;
- if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
+ if (!isConnected)
+ return {
+ kind: "unavailable",
+ message: t("providerUsage.hostUnavailable", { host: hostLabel }),
+ };
if (!supportsUsage) {
- return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
+ return {
+ kind: "unavailable",
+ message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
+ };
}
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
@@ -138,17 +144,20 @@ export type AgentUsageView =
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[] };
-export function resolveAgentUsageView(input: {
- canReport: boolean;
- query: UsageQueryState;
-}): AgentUsageView {
+export function resolveAgentUsageView(
+ input: {
+ canReport: boolean;
+ query: UsageQueryState;
+ },
+ t: TFunction = i18n.t,
+): AgentUsageView {
const { canReport, query } = input;
if (!canReport) return { kind: "none" };
// A failed request keeps the reports from before it, or those that streamed in before it failed;
// shown alone they would pass for the agent's complete, current usage.
if (query.error) {
const reason = query.error instanceof Error ? query.error.message : String(query.error);
- return { kind: "error", message: usageCopy.agentError(reason) };
+ return { kind: "error", message: t("providerUsage.agentError", { reason }) };
}
if (query.data) {
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
@@ -194,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
-export function resolveUsageReport(entry: UsageReportEntry) {
+export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
const available = entry.report.status === "available";
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
@@ -204,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) {
if (!available) {
if (entry.loginErrors)
messages.push(
- ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
+ ...entry.loginErrors.map(
+ (login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
+ ),
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
- else messages.push(usageProblemText(entry.report));
+ else messages.push(usageProblemText(entry.report, t));
}
return {
windows: content?.windows ?? [],
@@ -224,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) {
};
}
-function usageProblemText(report: UsageReport): string {
+function usageProblemText(report: UsageReport, t: TFunction): string {
if (report.status === "error") return report.error;
- if (report.status === "unavailable") return usageCopy.problem(report.problem);
+ if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
return "";
}
diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx
index 886c21e190..b735b612aa 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";
@@ -8,20 +9,23 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { settingsStyles } from "@/styles/settings";
-import { usageCopy } from "./copy";
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() {
+ const { t } = useTranslation();
+ const displayAsOptions: SegmentedControlOption[] = [
+ { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
+ {
+ value: "remaining",
+ label: t("providerUsage.displayRemaining"),
+ testID: "usage-display-remaining",
+ },
+ ];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
@@ -29,7 +33,7 @@ export function UsageOptions() {
@@ -41,21 +45,21 @@ export function UsageOptions() {
-
+
{
+ const { i18n } = await import("@/i18n/i18next");
+ await i18n.changeLanguage("ja");
+ try {
+ const entries = [
+ report({
+ sourceId: "test",
+ sourceLabel: "Test",
+ windows: [
+ { id: "session", label: "Session", summary: true, usedPct: 70 },
+ { id: "weekly", label: "My allowance", summary: true, usedPct: 20 },
+ ],
+ }),
+ ];
+ expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([
+ ["Test セッション 70% 使用済み", "セッション"],
+ ["Test My allowance 20% 使用済み", "My allowance"],
+ ]);
+ expect(windows(entries, preferences(null, "remaining"))[0].label).toBe(
+ "Test セッション 残り 30%",
+ );
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+});
diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts
index 547b2c3e48..21fd4cf06c 100644
--- a/packages/app/src/usage/pinned.ts
+++ b/packages/app/src/usage/pinned.ts
@@ -1,4 +1,6 @@
-import { formatPct } from "./format";
+import type { TFunction } from "i18next";
+import { i18n } from "@/i18n/i18next";
+import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
@@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[]
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
+ t: TFunction = i18n.t,
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
- const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return groupBySource(reports).flatMap((entry) => {
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
@@ -84,8 +86,8 @@ export function resolvePinnedUsage(
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
- label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
- shortLabel: window.shortLabel ?? window.label,
+ label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`,
+ shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t),
percent,
percentText,
tone: windowTone(window),
diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts
index 7b6f210543..c5ec32bd2d 100644
--- a/packages/app/src/usage/queries.ts
+++ b/packages/app/src/usage/queries.ts
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import {
@@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const refresh = useCallback(() => {
void refreshReports(queryClient, serverId).catch(() => undefined);
}, [queryClient, serverId]);
+ const { t } = useTranslation();
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
- const view = resolveUsageView({
- hostLabel,
- isConnected,
- supportsUsage: isSupported,
- query: toQueryState(query),
- });
+ const view = resolveUsageView(
+ {
+ hostLabel,
+ isConnected,
+ supportsUsage: isSupported,
+ query: toQueryState(query),
+ },
+ t,
+ );
return { view, refresh };
}
@@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
retry: false,
staleTimeMs: REPORTS_STALE_TIME_MS,
});
- return resolveAgentUsageView({ canReport, query: toQueryState(query) });
+ const { t } = useTranslation();
+ return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t);
}
/** Whether a host is connected and reports usage, read without fetching anything. */
diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx
index 48754c4e19..473e4442cc 100644
--- a/packages/app/src/usage/refresh-button.tsx
+++ b/packages/app/src/usage/refresh-button.tsx
@@ -1,10 +1,10 @@
+import { useTranslation } from "react-i18next";
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
-import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
* header's island, so its highlight is round.
*/
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
+ const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
resolvePinnedUsage(reports, preferences), [preferences, reports]);
+ return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx
index 8305c3f878..bf92eaa60f 100644
--- a/packages/app/src/usage/usage-modal.tsx
+++ b/packages/app/src/usage/usage-modal.tsx
@@ -4,7 +4,6 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
-import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
@@ -22,11 +21,12 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
+ const { t } = useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
-
+
);
}
diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx
index ffb40e985d..26699fd447 100644
--- a/packages/app/src/usage/usage-section.tsx
+++ b/packages/app/src/usage/usage-section.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
-import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
@@ -50,26 +50,32 @@ export function UsageBody({
display: UsageDisplay;
onRefresh: () => void;
}) {
+ const { t } = useTranslation();
if (view.kind === "unavailable") {
return ;
}
if (view.kind === "loading") {
- return ;
+ return ;
}
if (view.kind === "error") {
return (
-
+
);
}
if (view.reports.length === 0) {
- return ;
+ return ;
}
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..c21c1398cb
--- /dev/null
+++ b/packages/app/src/usage/use-relative-time-tick.ts
@@ -0,0 +1,13 @@
+import { useEffect, useState } from "react";
+import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
+
+/** The current time as a render input, updated while a relative-time label is mounted. */
+export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number {
+ const [now, setNow] = useState(Date.now);
+
+ useEffect(() => {
+ if (!enabled) return undefined;
+ return subscribeToRelativeTimeTick("minute", () => setNow(Date.now()));
+ }, [enabled]);
+ return now;
+}
diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx
index 79ea6b9f2d..9082f2dbac 100644
--- a/packages/app/src/usage/window-bar.tsx
+++ b/packages/app/src/usage/window-bar.tsx
@@ -1,15 +1,21 @@
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,21 +45,24 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
+ const { t, i18n } = useTranslation();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
+ const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null });
const isAtRisk =
window.runsOutAt != null &&
window.shortfallPct != null &&
- (!retained || Date.parse(window.runsOutAt) > Date.now());
+ (!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk
- ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
- : formatResetLabel(window.resetsAt, retained);
+ ? formatRunsOutLabel(window.runsOutAt, now, t)
+ : formatResetLabel(window.resetsAt, retained, now, t);
- const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
+ const value =
+ shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
- label: window.label,
+ label: formatProviderUsageLabel(window.id, window.label, t),
value,
trailing,
isAtRisk,
@@ -124,7 +133,7 @@ function WindowRowContent({
{label}
-
+
{value}
{trailing ? (
{` · ${trailing}`}
@@ -142,6 +151,7 @@ function WindowRowContent({
const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
+ const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
@@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
- {pinned ? usageCopy.unpin : usageCopy.pin}
+
+ {pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
+
);
@@ -209,6 +221,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/vitest.config.ts b/packages/app/vitest.config.ts
index e2d67aca70..7a45d4a771 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -2,6 +2,7 @@ import { defineConfig, configDefaults } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
+import { transformSync } from "@babel/core";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
@@ -16,6 +17,25 @@ const resolvePackageEntry = (packageName: string) => {
};
export default defineConfig({
+ plugins: [
+ {
+ name: "compile-usage-components",
+ enforce: "pre",
+ transform(source, id) {
+ // These labels must react to time and language in the compiled app too.
+ if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
+ const result = transformSync(source, {
+ filename: id,
+ babelrc: false,
+ configFile: false,
+ parserOpts: { plugins: ["typescript", "jsx"] },
+ plugins: [["babel-plugin-react-compiler", { target: "19" }]],
+ sourceMaps: true,
+ });
+ if (result?.code) return { code: result.code, map: result.map };
+ },
+ },
+ ],
test: {
environment: "node",
exclude: [...configDefaults.exclude, "e2e/**"],
@@ -82,6 +102,7 @@ export default defineConfig({
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
include: [
"react/jsx-runtime",
+ "react/compiler-runtime",
"react-native-gesture-handler > hoist-non-react-statics",
"react-native-gesture-handler > invariant",
],
diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts
index 86aef1b880..d745f983be 100644
--- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts
+++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts
@@ -240,6 +240,8 @@ async function openTwoDraftTabs(page: Page, workspaceId: string) {
async function expectTabNumberNavigation(page: Page) {
const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
+ await expect(page.getByTestId("settings-sidebar")).not.toBeVisible();
+ await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+1");
await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+2");