feat(app): support locale plural rules (#40370)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-04 04:36:57 +00:00
committed by GitHub
parent c7aadc83e1
commit b1553c2b5c
33 changed files with 289 additions and 60 deletions
@@ -1097,22 +1097,16 @@ export function ContextToolGroup(props: {
<AnimatedCountList
items={[
{
key: "read",
key: "ui.messagePart.context.read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
key: "ui.messagePart.context.search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
key: "ui.messagePart.context.list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
@@ -441,10 +441,7 @@ export function SessionTurn(
>
<div data-slot="session-turn-diffs-header">
<span data-slot="session-turn-diffs-label">
{i18n.t(
edited() === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
{ count: String(edited()) },
)}
{i18n.plural("ui.sessionTurn.diffs.changed", edited())}
</span>
<DiffChanges changes={diffs()} />
<Show when={overflow() > 0}>
@@ -1,5 +1,6 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
@@ -23,14 +24,16 @@ function common(one: string, other: string) {
}
}
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
const one = createMemo(() => split(props.one))
const other = createMemo(() => split(props.other))
const singular = createMemo(() => Math.round(props.count) === 1)
const active = createMemo(() => (singular() ? one() : other()))
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
const i18n = useI18n()
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category()))))
const suffix = createMemo(() => common(one().after, other().after))
const splitSuffix = createMemo(
() =>
(category() === "one" || category() === "other") &&
one().before === other().before &&
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
)
@@ -38,7 +41,7 @@ export function AnimatedCountLabel(props: { count: number; one: string; other: s
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (singular()) return suffix().one
if (category() === "one") return suffix().one
return suffix().other
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
@@ -25,9 +25,6 @@ as it appears in the context tool group on the session page.`,
const TEXT = {
active: "Exploring",
done: "Explored",
read: { one: "{{count}} read", other: "{{count}} reads" },
search: { one: "{{count}} search", other: "{{count}} searches" },
list: { one: "{{count}} list", other: "{{count}} lists" },
} as const
function rand(min: number, max: number) {
@@ -118,9 +115,9 @@ export const Playground = {
}
const items = (): CountItem[] => [
{ key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other },
{ key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other },
{ key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other },
{ key: "ui.messagePart.context.read", count: reads() },
{ key: "ui.messagePart.context.search", count: searches() },
{ key: "ui.messagePart.context.list", count: lists() },
]
return (
@@ -210,8 +207,8 @@ export const Empty = {
<ToolStatusTitle active activeText="Exploring" doneText="Explored" split={false} />
<AnimatedCountList
items={[
{ key: "read", count: 0, one: "{{count}} read", other: "{{count}} reads" },
{ key: "search", count: 0, one: "{{count}} search", other: "{{count}} searches" },
{ key: "ui.messagePart.context.read", count: 0 },
{ key: "ui.messagePart.context.search", count: 0 },
]}
fallback=""
/>
@@ -226,9 +223,9 @@ export const Done = {
<span style={{ "font-weight": "400", color: "var(--text-base, #ccc)" }}>
<AnimatedCountList
items={[
{ key: "read", count: 5, one: "{{count}} read", other: "{{count}} reads" },
{ key: "search", count: 3, one: "{{count}} search", other: "{{count}} searches" },
{ key: "list", count: 1, one: "{{count}} list", other: "{{count}} lists" },
{ key: "ui.messagePart.context.read", count: 5 },
{ key: "ui.messagePart.context.search", count: 3 },
{ key: "ui.messagePart.context.list", count: 1 },
]}
fallback=""
/>
@@ -1,11 +1,10 @@
import { Index, createMemo } from "solid-js"
import type { UiI18nPluralKey } from "@opencode-ai/ui/context"
import { AnimatedCountLabel } from "./tool-count-label"
export type CountItem = {
key: string
key: UiI18nPluralKey
count: number
one: string
other: string
}
export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) {
@@ -36,11 +35,7 @@ export function AnimatedCountList(props: { items: CountItem[]; fallback?: string
</span>
<span data-slot="tool-count-summary-item" data-active={active() ? "true" : "false"}>
<span data-slot="tool-count-summary-item-inner">
<AnimatedCountLabel
one={item().one}
other={item().other}
count={Math.max(0, Math.round(item().count))}
/>
<AnimatedCountLabel plural={item().key} count={Math.max(0, Math.round(item().count))} />
</span>
</span>
</>