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

@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
import type { DiagnosticEntry } from "./diagnostics"
export function renderDiagnosticsSection(
t: (key: string, params?: Record<string, unknown>) => string,
entries: DiagnosticEntry[],
expanded: boolean,
toggle: () => void,
@@ -22,13 +23,13 @@ export function renderDiagnosticsSection(
<span class="tool-call-emoji" aria-hidden="true">
🛠
</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-summary">{t("toolCall.diagnostics.title")}</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>
{fileLabel}
</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics" role="region" aria-label={t("toolCall.diagnostics.ariaLabel")}>
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (

View File

@@ -1,5 +1,6 @@
import type { ToolState } from "@opencode-ai/sdk"
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
import { tGlobal } from "../../lib/i18n"
interface LspRangePosition {
line?: number
@@ -40,9 +41,9 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
}
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 }
if (tone === "error") return { label: tGlobal("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: tGlobal("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {

View File

@@ -19,6 +19,7 @@ export function createDiffContentRenderer(params: {
preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean>
t: (key: string, params?: Record<string, unknown>) => string
diffCache: CacheHandle
permissionDiffCache: CacheHandle
scrollHelpers: ToolScrollHelpers
@@ -27,7 +28,9 @@ export function createDiffContentRenderer(params: {
}) {
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const toolbarLabel = options?.label || (relativePath
? params.t("toolCall.diff.label.withPath", { path: relativePath })
: params.t("toolCall.diff.label"))
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
@@ -67,7 +70,7 @@ export function createDiffContentRenderer(params: {
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
<button
@@ -76,7 +79,7 @@ export function createDiffContentRenderer(params: {
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
>
Split
{params.t("toolCall.diff.viewMode.split")}
</button>
<button
type="button"
@@ -84,7 +87,7 @@ export function createDiffContentRenderer(params: {
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
>
Unified
{params.t("toolCall.diff.viewMode.unified")}
</button>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { Show, type Accessor, type JSXElement } from "solid-js"
import type { PermissionRequestLike } from "../../types/permission"
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
import { getPermissionSessionId } from "../../types/permission"
import { useI18n } from "../../lib/i18n"
import type { DiffPayload, DiffRenderOptions } from "./types"
import { getRelativePath } from "./utils"
@@ -18,6 +19,8 @@ export type PermissionToolBlockProps = {
}
export function PermissionToolBlock(props: PermissionToolBlockProps) {
const { t } = useI18n()
const diffPayload = () => {
const permission = props.permission()
if (!permission) return null
@@ -48,7 +51,9 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
{(permission) => (
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{props.active() ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-label">
{props.active() ? t("toolCall.permission.status.required") : t("toolCall.permission.status.queued")}
</span>
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
</div>
<div class="tool-call-permission-body">
@@ -62,14 +67,14 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
variant: "permission-diff",
disableScrollTracking: true,
label: payload().filePath
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
: "Requested diff",
? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") })
: t("toolCall.permission.requestedDiff.label"),
})}
</div>
)}
</Show>
<Show when={!props.active()}>
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
<p class="tool-call-permission-queued-text">{t("toolCall.permission.queuedText")}</p>
</Show>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
@@ -79,7 +84,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("once")}
>
Allow Once
{t("toolCall.permission.actions.allowOnce")}
</button>
<button
type="button"
@@ -87,7 +92,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("always")}
>
Always Allow
{t("toolCall.permission.actions.alwaysAllow")}
</button>
<button
type="button"
@@ -95,17 +100,17 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("reject")}
>
Deny
{t("toolCall.permission.actions.deny")}
</button>
</div>
<Show when={props.active()}>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
<span>{t("toolCall.permission.shortcuts.deny")}</span>
</div>
</Show>
</div>

View File

@@ -1,6 +1,7 @@
import { createMemo, Show, For, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../../lib/i18n"
type QuestionOption = { label: string; description: string }
@@ -26,6 +27,8 @@ export type QuestionToolBlockProps = {
}
export function QuestionToolBlock(props: QuestionToolBlockProps) {
const { t } = useI18n()
const requestId = createMemo(() => {
const state = props.toolState()
const request = props.request()
@@ -163,9 +166,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
{props.active()
? t("toolCall.question.status.required")
: props.request()
? t("toolCall.question.status.queued")
: t("toolCall.question.status.questions")}
</span>
<span class="tool-call-permission-type">
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
</span>
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
</div>
<div class="tool-call-permission-body">
@@ -186,10 +195,10 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs">
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
</div>
<Show when={multi()}>
<div class="text-xs text-muted">Multiple</div>
<div class="text-xs text-muted">{t("toolCall.question.multiple")}</div>
</Show>
</div>
@@ -222,7 +231,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
<label
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title="Type a custom answer"
title={t("toolCall.question.custom.title")}
>
<input
type={inputType()}
@@ -244,11 +253,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}}
/>
<div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight">Custom answer</div>
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div>
<input
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
type="text"
placeholder="Type your own answer"
placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()}
value={customValue()}
onFocus={(e) => {
@@ -275,7 +284,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
disabled={submitDisabled()}
onClick={() => props.onSubmit()}
>
Submit
{t("toolCall.question.actions.submit")}
</button>
<button
type="button"
@@ -283,15 +292,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
disabled={props.submitting()}
onClick={() => props.onDismiss()}
>
Dismiss
{t("toolCall.question.actions.dismiss")}
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Submit</span>
<span>{t("toolCall.question.shortcuts.submit")}</span>
<kbd class="kbd">Esc</kbd>
<span>Dismiss</span>
<span>{t("toolCall.question.shortcuts.dismiss")}</span>
</div>
<Show when={props.error()}>
@@ -301,7 +310,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</Show>
<Show when={!props.active() && props.request()}>
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p>
</Show>
</div>
</div>

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

View File

@@ -1,6 +1,7 @@
import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en"
import { defaultRenderer } from "./renderers/default"
import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read"
@@ -43,12 +44,28 @@ function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
} as ToolCallPart
}
function interpolate(template: string, params?: Record<string, unknown>): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const value = params[key]
return value === undefined || value === null ? "" : String(value)
})
}
function createStaticT(): ToolRendererContext["t"] {
return (key, params) => {
const template = (enMessages as Record<string, string>)[key] ?? key
return interpolate(template, params)
}
}
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const toolStateAccessor = () => snapshot.state
const toolNameAccessor = () => snapshot.toolName
const toolCallAccessor = () => createStaticToolPart(snapshot)
const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined
const t = createStaticT()
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null
@@ -57,6 +74,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
toolCall: toolCallAccessor,
toolState: toolStateAccessor,
toolName: toolNameAccessor,
t,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown,

View File

@@ -53,6 +53,7 @@ export interface ToolRendererContext {
toolCall: Accessor<ToolCallPart>
toolState: Accessor<ToolState | undefined>
toolName: Accessor<string>
t: (key: string, params?: Record<string, unknown>) => string
messageVersion?: Accessor<number | undefined>
partVersion?: Accessor<number | undefined>
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null

View File

@@ -3,6 +3,7 @@ import { getLanguageFromPath } from "../../lib/markdown"
import type { ToolState } from "@opencode-ai/sdk"
import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger"
import { tGlobal } from "../../lib/i18n"
const log = getLogger("session")
@@ -61,16 +62,16 @@ export function getToolIcon(tool: string): string {
export function getToolName(tool: string): string {
switch (tool) {
case "bash":
return "Shell"
return tGlobal("toolCall.renderer.toolName.shell")
case "webfetch":
return "Fetch"
return tGlobal("toolCall.renderer.toolName.fetch")
case "invalid":
return "Invalid"
return tGlobal("toolCall.renderer.toolName.invalid")
case "todowrite":
case "todoread":
return "Plan"
return tGlobal("toolCall.renderer.toolName.plan")
case "apply_patch":
return "Apply patch"
return tGlobal("toolCall.renderer.toolName.applyPatch")
default: {
const normalized = tool.replace(/^opencode_/, "")
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
@@ -202,31 +203,31 @@ export function readToolStatePayload(state?: ToolState): {
export function getDefaultToolAction(toolName: string) {
switch (toolName) {
case "task":
return "Delegating..."
return tGlobal("toolCall.task.action.delegating")
case "bash":
return "Writing command..."
return tGlobal("toolCall.renderer.action.writingCommand")
case "edit":
return "Preparing edit..."
return tGlobal("toolCall.renderer.action.preparingEdit")
case "webfetch":
return "Fetching from the web..."
return tGlobal("toolCall.renderer.action.fetchingFromWeb")
case "glob":
return "Finding files..."
return tGlobal("toolCall.renderer.action.findingFiles")
case "grep":
return "Searching content..."
return tGlobal("toolCall.renderer.action.searchingContent")
case "list":
return "Listing directory..."
return tGlobal("toolCall.renderer.action.listingDirectory")
case "read":
return "Reading file..."
return tGlobal("toolCall.renderer.action.readingFile")
case "write":
return "Preparing write..."
return tGlobal("toolCall.renderer.action.preparingWrite")
case "todowrite":
case "todoread":
return "Planning..."
return tGlobal("toolCall.renderer.action.planning")
case "patch":
return "Preparing patch..."
return tGlobal("toolCall.renderer.action.preparingPatch")
case "apply_patch":
return "Preparing apply_patch..."
return tGlobal("toolCall.applyPatch.action.preparing")
default:
return "Working..."
return tGlobal("toolCall.renderer.action.working")
}
}