feat(ui): localize UI strings

Converts hardcoded UI copy to i18n keys across the app, adds global translation for non-component modules, and splits the English catalog into feature modules with duplicate-key detection.
This commit is contained in:
Shantur Rathore
2026-01-26 12:26:12 +00:00
parent 33939f4096
commit 5b1e21345f
88 changed files with 2080 additions and 822 deletions

View File

@@ -35,10 +35,10 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
function resolveDiagnosticsKey(
@@ -69,6 +69,7 @@ function resolveDiagnosticsKey(
function buildDiagnostics(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
t: (key: string, params?: Record<string, unknown>) => string,
): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, file)
if (!key) return []
@@ -82,7 +83,7 @@ function buildDiagnostics(
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const severityMeta = getSeverityMeta(tone, t)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
@@ -103,11 +104,14 @@ function buildDiagnostics(
return entries.sort((a, b) => a.severity - b.severity)
}
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
return (
<Show when={props.entries.length > 0}>
<div class="tool-call-diagnostics-wrapper">
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
<div
class="tool-call-diagnostics"
role="region"
aria-label={props.t("toolCall.diagnostics.ariaLabel.withLabel", { label: props.label })}
>
<div class="tool-call-diagnostics-body" role="list">
<For each={props.entries}>
@@ -134,19 +138,22 @@ function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string })
export const applyPatchRenderer: ToolRenderer = {
tools: ["apply_patch"],
getAction: () => "Preparing apply_patch...",
getTitle({ toolState }) {
getAction: ({ t }) => t("toolCall.applyPatch.action.preparing"),
getTitle({ toolState, t }) {
const state = toolState()
if (!state) return undefined
if (state.status === "pending") return getToolName("apply_patch")
const { metadata } = readToolStatePayload(state)
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
if (files.length > 0) {
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
const tool = getToolName("apply_patch")
return files.length === 1
? t("toolCall.applyPatch.title.withFileCount.one", { tool, count: files.length })
: t("toolCall.applyPatch.title.withFileCount.other", { tool, count: files.length })
}
return getToolName("apply_patch")
},
renderBody({ toolState, renderDiff, renderMarkdown }) {
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
const state = toolState()
if (!state || state.status === "pending") return null
@@ -170,10 +177,10 @@ export const applyPatchRenderer: ToolRenderer = {
<div class="tool-call-apply-patch">
<For each={files()}>
{(file, index) => {
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
return (
<div class="tool-call-apply-patch-file">
@@ -181,12 +188,12 @@ export const applyPatchRenderer: ToolRenderer = {
{renderDiff(
{ diffText, filePath },
{
label: `Diff · ${getRelativePath(labelBase)}`,
label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }),
cacheKey: `apply_patch:${labelBase}:${index()}`,
},
)}
</Show>
<DiagnosticsInline entries={entries()} label={labelBase} />
<DiagnosticsInline entries={entries()} label={labelBase} t={t} />
</div>
)
}}

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const bashRenderer: ToolRenderer = {
tools: ["bash"],
getAction: () => "Writing command...",
getAction: () => tGlobal("toolCall.renderer.action.writingCommand"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined
@@ -18,7 +19,7 @@ export const bashRenderer: ToolRenderer = {
}
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · Timeout: ${timeoutLabel}`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
},
renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState()

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const editRenderer: ToolRenderer = {
tools: ["edit"],
getAction: () => "Preparing edit...",
getAction: () => tGlobal("toolCall.renderer.action.preparingEdit"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const patchRenderer: ToolRenderer = {
tools: ["patch"],
getAction: () => "Preparing patch...",
getAction: () => tGlobal("toolCall.renderer.action.preparingPatch"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined

View File

@@ -2,12 +2,12 @@ import type { ToolRenderer } from "../types"
export const questionRenderer: ToolRenderer = {
tools: ["question"],
getAction: () => "Awaiting answers...",
getTitle({ toolState }) {
getAction: ({ t }) => t("toolCall.question.action.awaitingAnswers"),
getTitle({ toolState, t }) {
const state = toolState()
if (!state) return "Questions"
if (state.status === "completed") return "Questions"
return "Asking questions"
if (!state) return t("toolCall.question.title.questions")
if (state.status === "completed") return t("toolCall.question.title.questions")
return t("toolCall.question.title.askingQuestions")
},
renderBody() {
// The question tool UI is rendered by ToolCall itself so

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const readRenderer: ToolRenderer = {
tools: ["read"],
getAction: () => "Reading file...",
getAction: () => tGlobal("toolCall.renderer.action.readingFile"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined
@@ -15,11 +16,11 @@ export const readRenderer: ToolRenderer = {
const detailParts: string[] = []
if (typeof offset === "number") {
detailParts.push(`Offset: ${offset}`)
detailParts.push(tGlobal("toolCall.renderer.read.detail.offset", { offset }))
}
if (typeof limit === "number") {
detailParts.push(`Limit: ${limit}`)
detailParts.push(tGlobal("toolCall.renderer.read.detail.limit", { limit }))
}
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")

View File

@@ -37,18 +37,7 @@ function summarizeStatusIcon(status?: ToolState["status"]) {
}
function summarizeStatusLabel(status?: ToolState["status"]) {
switch (status) {
case "pending":
return "Pending"
case "running":
return "Running"
case "completed":
return "Completed"
case "error":
return "Error"
default:
return "Unknown"
}
return status
}
function describeTaskTitle(input: Record<string, any>) {
@@ -82,14 +71,14 @@ function describeToolTitle(item: TaskSummaryItem): string {
export const taskRenderer: ToolRenderer = {
tools: ["task"],
getAction: () => "Delegating...",
getAction: ({ t }) => t("toolCall.task.action.delegating"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined
const { input } = readToolStatePayload(state)
return describeTaskTitle(input)
},
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
const promptContent = createMemo(() => {
const state = toolState()
if (!state) return null
@@ -128,9 +117,9 @@ export const taskRenderer: ToolRenderer = {
const headerMeta = createMemo(() => {
const agent = agentLabel()
const model = modelLabel()
if (agent && model) return `Agent: ${agent} • Model: ${model}`
if (agent) return `Agent: ${agent}`
if (model) return `Model: ${model}`
if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model })
if (agent) return t("toolCall.task.meta.agent", { agent })
if (model) return t("toolCall.task.meta.model", { model })
return null
})
@@ -162,7 +151,7 @@ export const taskRenderer: ToolRenderer = {
<Show when={promptContent()}>
<section class="tool-call-task-section">
<header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">Prompt</span>
<span class="tool-call-task-section-title">{t("toolCall.task.sections.prompt")}</span>
<Show when={headerMeta()}>
<span class="tool-call-task-section-meta">{headerMeta()}</span>
</Show>
@@ -181,8 +170,8 @@ export const taskRenderer: ToolRenderer = {
<Show when={items().length > 0}>
<section class="tool-call-task-section">
<header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">Steps</span>
<span class="tool-call-task-section-meta">{items().length} steps</span>
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
</header>
<div class="tool-call-task-section-body">
<div
@@ -200,7 +189,10 @@ export const taskRenderer: ToolRenderer = {
const toolLabel = getToolName(item.tool)
const status = normalizeStatus(item.status ?? item.state?.status)
const statusIcon = summarizeStatusIcon(status)
const statusLabel = summarizeStatusLabel(status)
const statusKey = summarizeStatusLabel(status)
const statusLabel = statusKey
? t(`toolCall.status.${statusKey}`)
: t("toolCall.status.unknown")
const statusAttr = status ?? "pending"
return (
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
@@ -227,7 +219,7 @@ export const taskRenderer: ToolRenderer = {
<Show when={outputContent()}>
<section class="tool-call-task-section">
<header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">Output</span>
<span class="tool-call-task-section-title">{t("toolCall.task.sections.output")}</span>
<Show when={headerMeta()}>
<span class="tool-call-task-section-meta">{headerMeta()}</span>
</Show>

View File

@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
@@ -45,16 +46,16 @@ function summarizeTodos(todos: TodoViewItem[]) {
)
}
function getTodoStatusLabel(status: TodoViewStatus): string {
function getTodoStatusLabel(t: (key: string) => string, status: TodoViewStatus): string {
switch (status) {
case "completed":
return "Completed"
return t("toolCall.renderer.todo.status.completed")
case "in_progress":
return "In progress"
return t("toolCall.renderer.todo.status.inProgress")
case "cancelled":
return "Cancelled"
return t("toolCall.renderer.todo.status.cancelled")
default:
return "Pending"
return t("toolCall.renderer.todo.status.pending")
}
}
@@ -65,11 +66,12 @@ interface TodoListViewProps {
}
export function TodoListView(props: TodoListViewProps) {
const { t } = useI18n()
const todos = extractTodosFromState(props.state)
const counts = summarizeTodos(todos)
if (counts.total === 0) {
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
return <div class="tool-call-todo-empty">{props.emptyLabel ?? t("toolCall.renderer.todo.empty")}</div>
}
return (
@@ -77,7 +79,7 @@ export function TodoListView(props: TodoListViewProps) {
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
const label = getTodoStatusLabel(t, todo.status)
return (
<div
class="tool-call-todo-item"
@@ -108,20 +110,20 @@ export function TodoListView(props: TodoListViewProps) {
}
export function getTodoTitle(state?: ToolState): string {
if (!state) return "Plan"
if (!state) return tGlobal("toolCall.renderer.todo.title.plan")
const todos = extractTodosFromState(state)
if (state.status !== "completed" || todos.length === 0) return "Plan"
if (state.status !== "completed" || todos.length === 0) return tGlobal("toolCall.renderer.todo.title.plan")
const counts = summarizeTodos(todos)
if (counts.pending === counts.total) return "Creating plan"
if (counts.completed === counts.total) return "Completing plan"
return "Updating plan"
if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating")
if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing")
return tGlobal("toolCall.renderer.todo.title.updating")
}
export const todoRenderer: ToolRenderer = {
tools: ["todowrite", "todoread"],
getAction: () => "Planning...",
getAction: () => tGlobal("toolCall.renderer.action.planning"),
getTitle({ toolState }) {
return getTodoTitle(toolState())
},

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const webfetchRenderer: ToolRenderer = {
tools: ["webfetch"],
getAction: () => "Fetching from the web...",
getAction: () => tGlobal("toolCall.renderer.action.fetchingFromWeb"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined

View File

@@ -1,9 +1,10 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
export const writeRenderer: ToolRenderer = {
tools: ["write"],
getAction: () => "Preparing write...",
getAction: () => tGlobal("toolCall.renderer.action.preparingWrite"),
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined