feat(i18n): localize hardcoded application copy (#40377)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-04 16:56:03 +10:00
committed by GitHub
parent 6c3299103c
commit e85735028c
121 changed files with 4758 additions and 331 deletions
@@ -52,12 +52,12 @@ describe("message-file", () => {
})
test("labels attachment types from the basename extension", () => {
expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
expect(typeLabel(".gitignore", "text/plain")).toBe("File")
expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
expect(typeLabel("list.md", "text/plain", "File")).toBe("Markdown")
expect(typeLabel("/repo/src/main.ts", "text/plain", "File")).toBe("TypeScript")
expect(typeLabel("/tmp/report.pdf", "application/pdf", "File")).toBe("PDF")
expect(typeLabel("notes.xyz", "text/plain", "File")).toBe("XYZ")
expect(typeLabel("/home/user/my.project/Makefile", "text/plain", "File")).toBe("File")
expect(typeLabel(".gitignore", "text/plain", "File")).toBe("File")
expect(typeLabel("/repo/.env", "text/plain", "File")).toBe("File")
})
})
@@ -23,12 +23,12 @@ const LANGUAGE_NAMES = new Map<string, string>(
// attachments carry text/plain for all text files, so the label comes from the extension;
// filename may be an absolute path, so extract the basename before looking for one
export function typeLabel(filename: string, mime: string) {
export function typeLabel(filename: string, mime: string, fallback: string) {
if (mime === "application/pdf") return "PDF"
const base = getFilename(filename)
// idx 0 is a dotfile like .gitignore, not an extension
const idx = base.lastIndexOf(".")
const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
if (!suffix) return "File"
if (!suffix) return fallback
return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
}
@@ -459,10 +459,10 @@ function newLayout() {
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
}
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
return "Web Search"
function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI18n>) {
const name = provider === "parallel" ? "Parallel" : provider === "exa" ? "Exa" : undefined
if (name) return i18n.t("ui.tool.websearch.provider", { provider: name })
return i18n.t("ui.tool.websearch")
}
export function getToolInfo(
@@ -505,7 +505,7 @@ export function getToolInfo(
case "websearch":
return {
icon: "window-cursor",
title: webSearchProviderLabel(metadata?.provider),
title: webSearchProviderLabel(metadata?.provider, i18n),
subtitle: input.query,
}
case "task": {
@@ -1309,7 +1309,7 @@ export function UserMessageDisplay(props: {
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
{typeLabel(name, file.mime)}
{typeLabel(name, file.mime, i18n.t("ui.common.file"))}
</AttachmentCardV2>
</Show>
)
@@ -1594,7 +1594,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<ToolErrorCard
tool={part().tool}
error={error()}
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
title={
part().tool === "websearch"
? webSearchProviderLabel(partMetadata().provider, i18n)
: undefined
}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
@@ -1956,12 +1960,13 @@ ToolRegistry.register({
ToolRegistry.register({
name: "websearch",
render(props) {
const i18n = useI18n()
const query = createMemo(() => {
const value = props.input.query
if (typeof value !== "string") return ""
return value
})
const title = createMemo(() => webSearchProviderLabel(props.metadata.provider))
const title = createMemo(() => webSearchProviderLabel(props.metadata.provider, i18n))
return (
<BasicTool
@@ -1,6 +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"
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
@@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -46,6 +47,7 @@ export type PromptInputV2Props = {
}
export function PromptInputV2(props: PromptInputV2Props) {
const i18n = useI18n()
const state = props.controller.state
const view = props.controller.view
let editor: HTMLDivElement | undefined
@@ -87,14 +89,14 @@ export function PromptInputV2(props: PromptInputV2Props) {
/>
<Show when={state.popover.type !== "closed"}>
<PromptInputV2Popover
emptyLabel="No matching items"
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
? {
value: state.popover.query,
label: "Commands",
label: i18n.t("ui.promptInput.commands"),
placeholder: "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
@@ -124,7 +126,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
Drop files to attach
{i18n.t("ui.promptInput.dropFiles")}
</div>
</Show>
@@ -133,7 +135,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
attachments={props.controller.attachments()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel="Remove attachment"
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
@@ -151,7 +153,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
aria-label={i18n.t("ui.promptInput.label")}
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
@@ -186,7 +188,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
classList={{ "font-mono!": state.mode === "shell" }}
>
{view.placeholder?.() ??
(state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")}
(state.mode === "shell" ? (
i18n.t("ui.promptInput.placeholder.shell")
) : (
i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
))}
</div>
</Show>
</div>
@@ -200,13 +206,13 @@ export function PromptInputV2(props: PromptInputV2Props) {
>
<PromptInputV2AddMenu
disabled={state.mode === "shell"}
title="Add images and files"
title={i18n.t("ui.promptInput.add")}
keybind={props.attachKeybind ?? ["Mod", "U"]}
attachLabel="Images and files"
attachLabel={i18n.t("ui.promptInput.attachments")}
attachShortcut={props.attachShortcut ?? "Mod+U"}
commandsLabel="Commands"
contextLabel="Context"
shellLabel="Shell command"
commandsLabel={i18n.t("ui.promptInput.commands")}
contextLabel={i18n.t("ui.promptInput.context")}
shellLabel={i18n.t("ui.promptInput.shell")}
onAttach={props.controller.attach}
onCommands={props.controller.openCommands}
onContext={props.controller.openContext}
@@ -214,7 +220,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
/>
<Show when={view.agent} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control} />
<PromptInputV2ConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show
@@ -223,7 +233,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
<Show when={view.model} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect
title="Choose model"
title={i18n.t("ui.promptInput.chooseModel")}
keybind={["Mod", "M"]}
control={control}
model
@@ -238,7 +248,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
{(control) => (
<Show when={control.options().length > 1}>
<PromptInputV2ConfiguredSelect
title="Choose model variant"
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
@@ -250,8 +260,8 @@ export function PromptInputV2(props: PromptInputV2Props) {
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel="Send"
stopLabel="Stop"
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={props.controller.submit}
onStop={props.controller.stop}
/>
@@ -377,6 +387,7 @@ export function PromptInputV2Attachments(props: {
onCommentClick?: (comment: PromptInputV2Comment) => void
onCommentRemove?: (comment: PromptInputV2Comment) => void
}) {
const i18n = useI18n()
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
@@ -404,7 +415,7 @@ export function PromptInputV2Attachments(props: {
<button
type="button"
onClick={() => props.onCommentRemove?.(comment)}
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
@@ -420,7 +431,7 @@ export function PromptInputV2Attachments(props: {
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCardV2>
}
>
@@ -436,7 +447,7 @@ export function PromptInputV2Attachments(props: {
<button
type="button"
onClick={() => props.onAttachmentRemove(attachment)}
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
@@ -447,11 +458,11 @@ export function PromptInputV2Attachments(props: {
</div>
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
class="pointer-events-none absolute inset-y-0 start-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
class="pointer-events-none absolute inset-y-0 end-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
</div>
</Show>
@@ -571,7 +582,7 @@ export function PromptInputV2Select(props: {
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<span class="-ms-0.5 -me-1 flex shrink-0">
<IconV2 name="chevron-down" />
</span>
</MenuV2.Trigger>
@@ -637,7 +648,7 @@ export function PromptInputV2Popover(props: {
<button
type="button"
data-suggestion-id={item.id}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-v2-overlay-simple-overlay-hover"
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}