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

@@ -3,6 +3,7 @@ import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
import { getLogger } from "./logger"
import { tGlobal } from "./i18n"
const log = getLogger("actions")
@@ -17,19 +18,19 @@ export async function promptForCommandArguments(command: SDKCommand): Promise<st
}
try {
return await showPromptDialog(`Arguments for /${command.name}`, {
title: "Custom command",
return await showPromptDialog(tGlobal("commands.custom.argumentsPrompt.message", { name: command.name }), {
title: tGlobal("commands.custom.argumentsPrompt.title"),
variant: "info",
inputLabel: "Arguments",
inputPlaceholder: "e.g. foo bar",
inputLabel: tGlobal("commands.custom.argumentsPrompt.inputLabel"),
inputPlaceholder: tGlobal("commands.custom.argumentsPrompt.inputPlaceholder"),
inputDefaultValue: "",
confirmLabel: "Run",
cancelLabel: "Cancel",
confirmLabel: tGlobal("commands.custom.argumentsPrompt.confirmLabel"),
cancelLabel: tGlobal("commands.custom.argumentsPrompt.cancelLabel"),
})
} catch (error) {
log.error("Failed to prompt for command arguments", error)
showAlertDialog("Failed to open arguments prompt.", {
title: "Command arguments",
showAlertDialog(tGlobal("commands.custom.argumentsPrompt.openFailed.message"), {
title: tGlobal("commands.custom.argumentsPrompt.openFailed.title"),
variant: "error",
})
return null
@@ -45,14 +46,14 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
return commands.map((cmd) => ({
id: `custom:${instanceId}:${cmd.name}`,
label: formatCommandLabel(cmd.name),
description: cmd.description ?? "Custom command",
description: () => cmd.description ?? tGlobal("commands.custom.entries.descriptionFallback"),
category: "Custom Commands",
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
action: async () => {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId || sessionId === "info") {
showAlertDialog("Select a session before running a custom command.", {
title: "Session required",
showAlertDialog(tGlobal("commands.custom.sessionRequired.message"), {
title: tGlobal("commands.custom.sessionRequired.title"),
variant: "warning",
})
return
@@ -65,8 +66,8 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
log.error("Failed to run custom command", error)
showAlertDialog("Failed to run custom command. Check the console for details.", {
title: "Command failed",
showAlertDialog(tGlobal("commands.custom.runFailed.message"), {
title: tGlobal("commands.custom.runFailed.title"),
variant: "error",
})
}

View File

@@ -6,14 +6,20 @@ export interface KeyboardShortcut {
alt?: boolean
}
export type Resolvable<T> = T | (() => T)
export function resolveResolvable<T>(value: Resolvable<T>): T {
return typeof value === "function" ? (value as () => T)() : value
}
export interface Command {
id: string
label: string | (() => string)
description: string
keywords?: string[]
label: Resolvable<string>
description: Resolvable<string>
keywords?: Resolvable<string[]>
shortcut?: KeyboardShortcut
action: () => void | Promise<void>
category?: string
category?: Resolvable<string>
}
export function createCommandRegistry() {
@@ -47,11 +53,15 @@ export function createCommandRegistry() {
const lowerQuery = query.toLowerCase()
return getAll().filter((cmd) => {
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
const label = resolveResolvable(cmd.label)
const description = resolveResolvable(cmd.description)
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
const labelMatch = label.toLowerCase().includes(lowerQuery)
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
return labelMatch || descMatch || keywordMatch
const descMatch = description.toLowerCase().includes(lowerQuery)
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
const categoryMatch = category?.toLowerCase().includes(lowerQuery)
return labelMatch || descMatch || keywordMatch || categoryMatch
})
}

View File

@@ -13,9 +13,17 @@ import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
const log = getLogger("actions")
function splitKeywords(key: string): string[] {
return tGlobal(key)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
@@ -61,20 +69,20 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "new-instance",
label: "New Instance",
description: "Open folder picker to create new instance",
label: () => tGlobal("commands.newInstance.label"),
description: () => tGlobal("commands.newInstance.description"),
category: "Instance",
keywords: ["folder", "project", "workspace"],
keywords: () => splitKeywords("commands.newInstance.keywords"),
shortcut: { key: "N", meta: true },
action: options.handleNewInstanceRequest,
})
commandRegistry.register({
id: "close-instance",
label: "Close Instance",
description: "Stop current instance's server",
label: () => tGlobal("commands.closeInstance.label"),
description: () => tGlobal("commands.closeInstance.description"),
category: "Instance",
keywords: ["stop", "quit", "close"],
keywords: () => splitKeywords("commands.closeInstance.keywords"),
shortcut: { key: "W", meta: true },
action: async () => {
const instance = activeInstance()
@@ -85,10 +93,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "instance-next",
label: "Next Instance",
description: "Cycle to next instance tab",
label: () => tGlobal("commands.nextInstance.label"),
description: () => tGlobal("commands.nextInstance.description"),
category: "Instance",
keywords: ["switch", "navigate"],
keywords: () => splitKeywords("commands.nextInstance.keywords"),
shortcut: { key: "]", meta: true },
action: () => {
const ids = Array.from(instances().keys())
@@ -101,10 +109,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "instance-prev",
label: "Previous Instance",
description: "Cycle to previous instance tab",
label: () => tGlobal("commands.previousInstance.label"),
description: () => tGlobal("commands.previousInstance.description"),
category: "Instance",
keywords: ["switch", "navigate"],
keywords: () => splitKeywords("commands.previousInstance.keywords"),
shortcut: { key: "[", meta: true },
action: () => {
const ids = Array.from(instances().keys())
@@ -117,10 +125,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "new-session",
label: "New Session",
description: "Create a new parent session",
label: () => tGlobal("commands.newSession.label"),
description: () => tGlobal("commands.newSession.description"),
category: "Session",
keywords: ["create", "start"],
keywords: () => splitKeywords("commands.newSession.keywords"),
shortcut: { key: "N", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
@@ -131,10 +139,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "close-session",
label: "Close Session",
description: "Close current parent session",
label: () => tGlobal("commands.closeSession.label"),
description: () => tGlobal("commands.closeSession.description"),
category: "Session",
keywords: ["close", "stop"],
keywords: () => splitKeywords("commands.closeSession.keywords"),
shortcut: { key: "W", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
@@ -146,10 +154,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "cleanup-blank-sessions",
label: "Scrub Sessions",
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
label: () => tGlobal("commands.scrubSessions.label"),
description: () => tGlobal("commands.scrubSessions.description"),
category: "Session",
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
keywords: () => splitKeywords("commands.scrubSessions.keywords"),
action: async () => {
const instance = activeInstance()
if (!instance) return
@@ -159,10 +167,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "switch-to-info",
label: "Instance Info",
description: "Open the instance overview for logs and status",
label: () => tGlobal("commands.instanceInfo.label"),
description: () => tGlobal("commands.instanceInfo.description"),
category: "Instance",
keywords: ["info", "logs", "console", "output"],
keywords: () => splitKeywords("commands.instanceInfo.keywords"),
shortcut: { key: "L", meta: true, shift: true },
action: () => {
const instance = activeInstance()
@@ -172,10 +180,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "session-next",
label: "Next Session",
description: "Cycle to next session tab",
label: () => tGlobal("commands.nextSession.label"),
description: () => tGlobal("commands.nextSession.description"),
category: "Session",
keywords: ["switch", "navigate"],
keywords: () => splitKeywords("commands.nextSession.keywords"),
shortcut: { key: "]", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
@@ -197,10 +205,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "session-prev",
label: "Previous Session",
description: "Cycle to previous session tab",
label: () => tGlobal("commands.previousSession.label"),
description: () => tGlobal("commands.previousSession.description"),
category: "Session",
keywords: ["switch", "navigate"],
keywords: () => splitKeywords("commands.previousSession.keywords"),
shortcut: { key: "[", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
@@ -223,10 +231,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "compact",
label: "Compact Session",
description: "Summarize and compact the current session",
label: () => tGlobal("commands.compactSession.label"),
description: () => tGlobal("commands.compactSession.description"),
category: "Session",
keywords: ["/compact", "summarize", "compress"],
keywords: () => ["/compact", ...splitKeywords("commands.compactSession.keywords")],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
@@ -247,9 +255,9 @@ export function useCommands(options: UseCommandsOptions) {
)
} catch (error) {
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {
title: "Compact failed",
const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback")
showAlertDialog(tGlobal("commands.compactSession.alert.message", { message }), {
title: tGlobal("commands.compactSession.alert.title"),
variant: "error",
})
}
@@ -275,10 +283,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "undo",
label: "Undo Last Message",
description: "Revert the last message",
label: () => tGlobal("commands.undoLastMessage.label"),
description: () => tGlobal("commands.undoLastMessage.description"),
category: "Session",
keywords: ["/undo", "revert", "undo"],
keywords: () => ["/undo", ...splitKeywords("commands.undoLastMessage.keywords")],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
@@ -320,8 +328,8 @@ export function useCommands(options: UseCommandsOptions) {
}
if (!messageID) {
showAlertDialog("Nothing to undo", {
title: "No actions to undo",
showAlertDialog(tGlobal("commands.undoLastMessage.none.message"), {
title: tGlobal("commands.undoLastMessage.none.title"),
variant: "info",
})
return
@@ -351,8 +359,8 @@ export function useCommands(options: UseCommandsOptions) {
}
} catch (error) {
log.error("Failed to revert message", error)
showAlertDialog("Failed to revert message", {
title: "Undo failed",
showAlertDialog(tGlobal("commands.undoLastMessage.failed.message"), {
title: tGlobal("commands.undoLastMessage.failed.title"),
variant: "error",
})
}
@@ -362,10 +370,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "open-model-selector",
label: "Open Model Selector",
description: "Choose a different model",
label: () => tGlobal("commands.openModelSelector.label"),
description: () => tGlobal("commands.openModelSelector.description"),
category: "Agent & Model",
keywords: ["model", "llm", "ai"],
keywords: () => splitKeywords("commands.openModelSelector.keywords"),
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const instance = activeInstance()
@@ -376,10 +384,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "open-variant-selector",
label: "Select Model Variant",
description: "Choose a thinking effort for the current model",
label: () => tGlobal("commands.selectModelVariant.label"),
description: () => tGlobal("commands.selectModelVariant.description"),
category: "Agent & Model",
keywords: ["variant", "thinking", "reasoning", "effort"],
keywords: () => splitKeywords("commands.selectModelVariant.keywords"),
shortcut: { key: "T", meta: true, shift: true },
action: () => {
const instance = activeInstance()
@@ -390,10 +398,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "open-agent-selector",
label: "Open Agent Selector",
description: "Choose a different agent",
label: () => tGlobal("commands.openAgentSelector.label"),
description: () => tGlobal("commands.openAgentSelector.description"),
category: "Agent & Model",
keywords: ["agent", "mode"],
keywords: () => splitKeywords("commands.openAgentSelector.keywords"),
shortcut: { key: "A", meta: true, shift: true },
action: () => {
const instance = activeInstance()
@@ -404,10 +412,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "clear-input",
label: "Clear Input",
description: "Clear the prompt textarea",
label: () => tGlobal("commands.clearInput.label"),
description: () => tGlobal("commands.clearInput.description"),
category: "Input & Focus",
keywords: ["clear", "reset"],
keywords: () => splitKeywords("commands.clearInput.keywords"),
shortcut: { key: "K", meta: true },
action: () => {
const textarea = findVisiblePromptTextarea()
@@ -417,19 +425,19 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "thinking",
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
description: "Show/hide AI thinking process",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`,
description: "Toggle tool call entries in the message timeline",
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: ["timeline", "tool", "toggle"],
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools,
})
@@ -437,11 +445,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
return `Thinking Blocks Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: "Toggle whether thinking blocks start expanded",
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -451,19 +460,25 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({
id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
description: "Display tool-call diffs side-by-side",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: ["diff", "split", "view"],
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
description: "Display tool-call diffs inline",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: ["diff", "unified", "view"],
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"),
})
@@ -471,11 +486,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: "Toggle default expansion for tool outputs",
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: ["tool", "output", "expand", "collapse"],
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -487,11 +503,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: "Toggle default expansion for diagnostics output",
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: ["diagnostics", "expand", "collapse"],
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -503,11 +520,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: "Show or hide token and cost stats for assistant messages",
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: ["token", "usage", "cost", "stats"],
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics,
})
@@ -515,21 +533,21 @@ export function useCommands(options: UseCommandsOptions) {
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
return `Auto-Cleanup Blank Sessions · ${enabled ? "Enabled" : "Disabled"}`
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: "Automatically clean up blank sessions when creating new ones",
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions,
})
commandRegistry.register({
id: "help",
label: "Show Help",
description: "Display keyboard shortcuts and help",
label: () => tGlobal("commands.showHelp.label"),
description: () => tGlobal("commands.showHelp.description"),
category: "System",
keywords: ["/help", "shortcuts", "help"],
keywords: () => ["/help", ...splitKeywords("commands.showHelp.keywords")],
action: () => {
log.info("Show help modal (not implemented)")
},

View File

@@ -1,10 +1,12 @@
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
import { createContext, createEffect, createMemo, createSignal, onCleanup, onMount, useContext } from "solid-js"
import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en"
import { enMessages } from "./messages/en/index"
type Messages = Record<string, string>
export type TranslateParams = Record<string, unknown>
export type Locale = "en"
const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const
@@ -57,9 +59,25 @@ function interpolate(template: string, params?: Record<string, unknown>): string
})
}
function translateFrom(messages: Messages, key: string, params?: TranslateParams): string {
const current = messages[key]
const fallback = enMessages[key as keyof typeof enMessages]
const template = current ?? fallback ?? key
return interpolate(template, params)
}
const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision()
return translateFrom(globalMessages, key, params)
}
export interface I18nContextValue {
locale: () => Locale
t: (key: string, params?: Record<string, unknown>) => string
t: (key: string, params?: TranslateParams) => string
}
const I18nContext = createContext<I18nContextValue>()
@@ -68,6 +86,8 @@ export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
const previousMessages = globalMessages
onMount(() => {
const detected = detectNavigatorLocale()
if (detected) setDetectedLocale(detected)
@@ -80,13 +100,20 @@ export const I18nProvider: ParentComponent = (props) => {
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
function t(key: string, params?: Record<string, unknown>): string {
const current = messages()[key]
const fallback = enMessages[key as keyof typeof enMessages]
const template = current ?? fallback ?? key
return interpolate(template, params)
function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params)
}
createEffect(() => {
globalMessages = messages()
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
globalMessages = previousMessages
setGlobalRevision((value) => value + 1)
})
const value: I18nContextValue = {
locale,
t,

View File

@@ -0,0 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "Advanced Settings",
"advancedSettings.environmentVariables.title": "Environment Variables",
"advancedSettings.environmentVariables.subtitle": "Applied whenever a new OpenCode instance starts",
"advancedSettings.actions.close": "Close",
} as const

View File

@@ -0,0 +1,29 @@
export const appMessages = {
"app.launchError.title": "Unable to launch OpenCode",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
"app.launchError.binaryPathLabel": "Binary path",
"app.launchError.errorOutputLabel": "Error output",
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
"app.launchError.close": "Close",
"app.launchError.closeTitle": "Close (Esc)",
"app.launchError.fallbackMessage": "Failed to launch workspace",
"app.stopInstance.confirmMessage": "Stop OpenCode instance? This will stop the server.",
"app.stopInstance.title": "Stop instance",
"app.stopInstance.confirmLabel": "Stop",
"app.stopInstance.cancelLabel": "Keep running",
"emptyState.logoAlt": "CodeNomad logo",
"emptyState.brandTitle": "CodeNomad",
"emptyState.tagline": "Select a folder to start coding with AI",
"emptyState.actions.selectFolder": "Select Folder",
"emptyState.actions.selecting": "Selecting...",
"emptyState.keyboardShortcut": "Keyboard shortcut: {shortcut}",
"emptyState.examples": "Examples: {example}",
"emptyState.multipleInstances": "You can have multiple instances of the same folder",
"releases.upgradeRequired.title": "Upgrade required",
"releases.upgradeRequired.message.withVersion": "Update to CodeNomad {version} to use the latest UI.",
"releases.upgradeRequired.message.noVersion": "Update CodeNomad to use the latest UI.",
"releases.upgradeRequired.action.getUpdate": "Get update",
} as const

View File

@@ -0,0 +1,160 @@
export const commandMessages = {
"commandPalette.title": "Command Palette",
"commandPalette.description": "Search and execute commands",
"commandPalette.searchPlaceholder": "Type a command or search...",
"commandPalette.empty": "No commands found for \"{query}\"",
"commandPalette.category.customCommands": "Custom Commands",
"commandPalette.category.instance": "Instance",
"commandPalette.category.session": "Session",
"commandPalette.category.agentModel": "Agent & Model",
"commandPalette.category.inputFocus": "Input & Focus",
"commandPalette.category.system": "System",
"commandPalette.category.other": "Other",
"commands.newInstance.label": "New Instance",
"commands.newInstance.description": "Open folder picker to create new instance",
"commands.newInstance.keywords": "folder, project, workspace",
"commands.closeInstance.label": "Close Instance",
"commands.closeInstance.description": "Stop current instance's server",
"commands.closeInstance.keywords": "stop, quit, close",
"commands.nextInstance.label": "Next Instance",
"commands.nextInstance.description": "Cycle to next instance tab",
"commands.nextInstance.keywords": "switch, navigate",
"commands.previousInstance.label": "Previous Instance",
"commands.previousInstance.description": "Cycle to previous instance tab",
"commands.previousInstance.keywords": "switch, navigate",
"commands.newSession.label": "New Session",
"commands.newSession.description": "Create a new parent session",
"commands.newSession.keywords": "create, start",
"commands.closeSession.label": "Close Session",
"commands.closeSession.description": "Close current parent session",
"commands.closeSession.keywords": "close, stop",
"commands.scrubSessions.label": "Scrub Sessions",
"commands.scrubSessions.description": "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
"commands.scrubSessions.keywords": "cleanup, blank, empty, sessions, remove, delete, scrub",
"commands.instanceInfo.label": "Instance Info",
"commands.instanceInfo.description": "Open the instance overview for logs and status",
"commands.instanceInfo.keywords": "info, logs, console, output",
"commands.nextSession.label": "Next Session",
"commands.nextSession.description": "Cycle to next session tab",
"commands.nextSession.keywords": "switch, navigate",
"commands.previousSession.label": "Previous Session",
"commands.previousSession.description": "Cycle to previous session tab",
"commands.previousSession.keywords": "switch, navigate",
"commands.compactSession.label": "Compact Session",
"commands.compactSession.description": "Summarize and compact the current session",
"commands.compactSession.keywords": "summarize, compress",
"commands.compactSession.errorFallback": "Failed to compact session",
"commands.compactSession.alert.title": "Compact failed",
"commands.compactSession.alert.message": "Compact failed: {message}",
"commands.undoLastMessage.label": "Undo Last Message",
"commands.undoLastMessage.description": "Revert the last message",
"commands.undoLastMessage.keywords": "revert, undo",
"commands.undoLastMessage.none.title": "No actions to undo",
"commands.undoLastMessage.none.message": "Nothing to undo",
"commands.undoLastMessage.failed.title": "Undo failed",
"commands.undoLastMessage.failed.message": "Failed to revert message",
"commands.openModelSelector.label": "Open Model Selector",
"commands.openModelSelector.description": "Choose a different model",
"commands.openModelSelector.keywords": "model, llm, ai",
"commands.selectModelVariant.label": "Select Model Variant",
"commands.selectModelVariant.description": "Choose a thinking effort for the current model",
"commands.selectModelVariant.keywords": "variant, thinking, reasoning, effort",
"commands.openAgentSelector.label": "Open Agent Selector",
"commands.openAgentSelector.description": "Choose a different agent",
"commands.openAgentSelector.keywords": "agent, mode",
"commands.clearInput.label": "Clear Input",
"commands.clearInput.description": "Clear the prompt textarea",
"commands.clearInput.keywords": "clear, reset",
"commands.thinkingBlocks.label.show": "Show Thinking Blocks",
"commands.thinkingBlocks.label.hide": "Hide Thinking Blocks",
"commands.thinkingBlocks.description": "Show/hide AI thinking process",
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
"commands.timelineToolCalls.label.hide": "Hide Timeline Tool Calls",
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
"commands.common.expanded": "Expanded",
"commands.common.collapsed": "Collapsed",
"commands.common.visible": "Visible",
"commands.common.hidden": "Hidden",
"commands.common.enabled": "Enabled",
"commands.common.disabled": "Disabled",
"commands.thinkingBlocksDefault.label": "Thinking Blocks Default · {state}",
"commands.thinkingBlocksDefault.description": "Toggle whether thinking blocks start expanded",
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
"commands.diffViewSplit.label": "Use Split Diff View",
"commands.diffViewSplit.description": "Display tool-call diffs side-by-side",
"commands.diffViewSplit.keywords": "diff, split, view",
"commands.diffViewUnified.label": "Use Unified Diff View",
"commands.diffViewUnified.description": "Display tool-call diffs inline",
"commands.diffViewUnified.keywords": "diff, unified, view",
"commands.toolOutputsDefault.label": "Tool Outputs Default · {state}",
"commands.toolOutputsDefault.description": "Toggle default expansion for tool outputs",
"commands.toolOutputsDefault.keywords": "tool, output, expand, collapse",
"commands.diagnosticsDefault.label": "Diagnostics Default · {state}",
"commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse",
"commands.tokenUsageDisplay.label": "Token Usage Display · {state}",
"commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats",
"commands.autoCleanupBlankSessions.label": "Auto-Cleanup Blank Sessions · {state}",
"commands.autoCleanupBlankSessions.description": "Automatically clean up blank sessions when creating new ones",
"commands.autoCleanupBlankSessions.keywords": "auto, cleanup, blank, sessions, toggle",
"commands.showHelp.label": "Show Help",
"commands.showHelp.description": "Display keyboard shortcuts and help",
"commands.showHelp.keywords": "shortcuts, help",
"commands.custom.argumentsPrompt.message": "Arguments for /{name}",
"commands.custom.argumentsPrompt.title": "Custom command",
"commands.custom.argumentsPrompt.inputLabel": "Arguments",
"commands.custom.argumentsPrompt.inputPlaceholder": "e.g. foo bar",
"commands.custom.argumentsPrompt.confirmLabel": "Run",
"commands.custom.argumentsPrompt.cancelLabel": "Cancel",
"commands.custom.argumentsPrompt.openFailed.message": "Failed to open arguments prompt.",
"commands.custom.argumentsPrompt.openFailed.title": "Command arguments",
"commands.custom.entries.descriptionFallback": "Custom command",
"commands.custom.sessionRequired.message": "Select a session before running a custom command.",
"commands.custom.sessionRequired.title": "Session required",
"commands.custom.runFailed.message": "Failed to run custom command. Check the console for details.",
"commands.custom.runFailed.title": "Command failed",
"unifiedPicker.loading.searching": "Searching...",
"unifiedPicker.loading.loadingWorkspace": "Loading workspace...",
"unifiedPicker.title.command": "Select Command",
"unifiedPicker.title.mention": "Select Agent or File",
"unifiedPicker.empty": "No results found",
"unifiedPicker.sections.commands": "COMMANDS",
"unifiedPicker.sections.agents": "AGENTS",
"unifiedPicker.sections.files": "FILES",
"unifiedPicker.badge.subagent": "subagent",
"unifiedPicker.footer.navigate": "navigate",
"unifiedPicker.footer.select": "select",
"unifiedPicker.footer.close": "close",
} as const

View File

@@ -0,0 +1,16 @@
export const dialogMessages = {
"alertDialog.fallbackTitle.info": "Heads up",
"alertDialog.fallbackTitle.warning": "Please review",
"alertDialog.fallbackTitle.error": "Something went wrong",
"alertDialog.actions.confirm": "Confirm",
"alertDialog.actions.run": "Run",
"alertDialog.actions.ok": "OK",
"alertDialog.actions.cancel": "Cancel",
"alertDialog.prompt.inputLabel": "Input",
"backgroundProcessOutputDialog.title": "Background Output",
"backgroundProcessOutputDialog.actions.close": "Close",
"backgroundProcessOutputDialog.loading": "Loading output...",
"backgroundProcessOutputDialog.truncatedNotice": "Output truncated for display.",
"backgroundProcessOutputDialog.loadErrorFallback": "Failed to load output.",
} as const

View File

@@ -0,0 +1,43 @@
export const filesystemMessages = {
"directoryBrowser.defaultDescription": "Browse folders under the configured workspace root.",
"directoryBrowser.close": "Close",
"directoryBrowser.currentFolder": "Current folder",
"directoryBrowser.selectCurrent": "Select Current",
"directoryBrowser.newFolder": "New Folder",
"directoryBrowser.creating": "Creating…",
"directoryBrowser.loadingFolders": "Loading folders…",
"directoryBrowser.noFolders": "No folders available.",
"directoryBrowser.upOneLevel": "Up one level",
"directoryBrowser.select": "Select",
"directoryBrowser.load.errorFallback": "Unable to load filesystem",
"directoryBrowser.createFolder.promptMessage": "Create a new folder in the current directory.",
"directoryBrowser.createFolder.title": "New Folder",
"directoryBrowser.createFolder.inputLabel": "Folder name",
"directoryBrowser.createFolder.inputPlaceholder": "e.g. my-new-project",
"directoryBrowser.createFolder.confirmLabel": "Create",
"directoryBrowser.createFolder.cancelLabel": "Cancel",
"directoryBrowser.createFolder.invalidNameMessage": "Please enter a single folder name.",
"directoryBrowser.createFolder.invalidNameDetail": "Folder names cannot include slashes, '..', or '~'.",
"directoryBrowser.createFolder.errorFallback": "Unable to create folder",
"filesystemBrowser.descriptionFallback": "Search for a path under the configured workspace root.",
"filesystemBrowser.rootLabel": "Root: {root}",
"filesystemBrowser.actions.close": "Close",
"filesystemBrowser.actions.retry": "Retry",
"filesystemBrowser.actions.select": "Select",
"filesystemBrowser.filterLabel": "Filter",
"filesystemBrowser.search.placeholder.directories": "Search for folders",
"filesystemBrowser.search.placeholder.files": "Search for files",
"filesystemBrowser.currentFolder.label": "Current folder",
"filesystemBrowser.currentFolder.selectCurrent": "Select Current",
"filesystemBrowser.loading.filesystem": "filesystem",
"filesystemBrowser.loading.workspaceRoot": "workspace root",
"filesystemBrowser.loading.loadingWithPath": "Loading {path}…",
"filesystemBrowser.empty.noEntries": "No entries found.",
"filesystemBrowser.navigation.upOneLevel": "Up one level",
"filesystemBrowser.hints.navigate": "Navigate",
"filesystemBrowser.hints.select": "Select",
"filesystemBrowser.hints.close": "Close",
"filesystemBrowser.errors.loadFilesystemFallback": "Unable to load filesystem",
"filesystemBrowser.errors.openDirectoryFallback": "Unable to open directory",
} as const

View File

@@ -1,4 +1,4 @@
export const enMessages = {
export const folderSelectionMessages = {
"folderSelection.logoAlt": "CodeNomad logo",
"folderSelection.tagline": "Select a folder to start coding with AI",
@@ -31,9 +31,4 @@ export const enMessages = {
"folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.",
"time.relative.justNow": "just now",
"time.relative.daysAgoShort": "{count}d ago",
"time.relative.hoursAgoShort": "{count}h ago",
"time.relative.minutesAgoShort": "{count}m ago",
} as const

View File

@@ -0,0 +1,36 @@
import { advancedSettingsMessages } from "./advancedSettings"
import { appMessages } from "./app"
import { commandMessages } from "./commands"
import { dialogMessages } from "./dialogs"
import { filesystemMessages } from "./filesystem"
import { folderSelectionMessages } from "./folderSelection"
import { instanceMessages } from "./instance"
import { loadingScreenMessages } from "./loadingScreen"
import { logMessages } from "./logs"
import { markdownMessages } from "./markdown"
import { messagingMessages } from "./messaging"
import { remoteAccessMessages } from "./remoteAccess"
import { sessionMessages } from "./session"
import { settingsMessages } from "./settings"
import { timeMessages } from "./time"
import { toolCallMessages } from "./toolCall"
import { mergeMessageParts } from "./merge"
export const enMessages = mergeMessageParts(
folderSelectionMessages,
advancedSettingsMessages,
loadingScreenMessages,
timeMessages,
appMessages,
dialogMessages,
filesystemMessages,
instanceMessages,
logMessages,
sessionMessages,
messagingMessages,
toolCallMessages,
markdownMessages,
settingsMessages,
remoteAccessMessages,
commandMessages,
)

View File

@@ -0,0 +1,125 @@
export const instanceMessages = {
"instanceTabs.new.title": "New instance (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "New instance",
"instanceTabs.remote.title": "Remote connect",
"instanceTabs.remote.ariaLabel": "Remote connect",
"instanceInfo.title": "Instance Information",
"instanceInfo.labels.folder": "Folder",
"instanceInfo.labels.project": "Project",
"instanceInfo.labels.versionControl": "Version Control",
"instanceInfo.labels.opencodeVersion": "OpenCode Version",
"instanceInfo.labels.binaryPath": "Binary Path",
"instanceInfo.labels.environmentVariables": "Environment Variables ({count})",
"instanceInfo.loading": "Loading...",
"instanceInfo.server.title": "Server",
"instanceInfo.server.port": "Port:",
"instanceInfo.server.pid": "PID:",
"instanceInfo.server.status": "Status:",
"instanceTab.status.permission": "Waiting on permission",
"instanceTab.status.compacting": "Compacting",
"instanceTab.status.working": "Working",
"instanceTab.status.idle": "Idle",
"instanceTab.status.ariaLabel": "Instance status: {status}",
"instanceTab.actions.close.ariaLabel": "Close instance",
"instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Instance Info",
"instanceShell.leftDrawer.pin": "Pin left drawer",
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
"instanceShell.leftDrawer.toggle.open": "Open left drawer",
"instanceShell.leftDrawer.toggle.close": "Close left drawer",
"instanceShell.rightDrawer.pin": "Pin right drawer",
"instanceShell.rightDrawer.unpin": "Unpin right drawer",
"instanceShell.rightDrawer.toggle.pinned": "Right drawer pinned",
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
"instanceShell.metrics.usedLabel": "Used",
"instanceShell.metrics.availableLabel": "Avail",
"instanceShell.commandPalette.openAriaLabel": "Open command palette",
"instanceShell.commandPalette.button": "Command Palette",
"instanceShell.connection.ariaLabel": "Connection {status}",
"instanceShell.connection.connected": "Connected",
"instanceShell.connection.connecting": "Connecting...",
"instanceShell.connection.disconnected": "Disconnected",
"instanceShell.connection.unknown": "Unknown",
"instanceWelcome.shortcuts.newSession": "New Session",
"instanceWelcome.empty.title": "No Previous Sessions",
"instanceWelcome.empty.description": "Create a new session below to get started",
"instanceWelcome.loading.title": "Loading Sessions",
"instanceWelcome.loading.description": "Fetching your previous sessions...",
"instanceWelcome.resume.title": "Resume Session",
"instanceWelcome.resume.subtitle.one": "{count} session available",
"instanceWelcome.resume.subtitle.other": "{count} sessions available",
"instanceWelcome.session.untitled": "Untitled Session",
"instanceWelcome.new.title": "Start New Session",
"instanceWelcome.new.subtitle": "Well reuse your last agent/model automatically",
"instanceWelcome.new.createButton": "Create Session",
"instanceWelcome.overlay.close": "Close",
"instanceWelcome.actions.viewInstanceInfo": "View Instance Info",
"instanceWelcome.actions.renameTitle": "Rename session",
"instanceWelcome.actions.deleteTitle": "Delete session",
"instanceWelcome.hints.navigate": "Navigate",
"instanceWelcome.hints.jump": "Jump",
"instanceWelcome.hints.firstLast": "First/Last",
"instanceWelcome.hints.resume": "Resume",
"instanceWelcome.hints.delete": "Delete",
"instanceWelcome.toasts.renameError": "Unable to rename session",
"instanceDisconnected.title": "Instance Disconnected",
"instanceDisconnected.folderFallback": "this workspace",
"instanceDisconnected.reasonFallback": "The server stopped responding",
"instanceDisconnected.description": "{folder} can no longer be reached. Close the tab to continue working.",
"instanceDisconnected.details.title": "Details",
"instanceDisconnected.details.folderLabel": "Folder:",
"instanceDisconnected.actions.closeInstance": "Close Instance",
"instanceShell.empty.title": "No session selected",
"instanceShell.empty.description": "Select a session to view messages",
"instanceShell.rightPanel.title": "Status Panel",
"instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",
"instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
"instanceShell.backgroundProcesses.actions.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",
"versionPill.appWithVersion": "App {version}",
"versionPill.ui": "UI",
"versionPill.uiWithVersion": "UI {version}",
"versionPill.source": " ({source})",
"opencodeBinarySelector.title": "OpenCode Binary",
"opencodeBinarySelector.subtitle": "Choose which executable OpenCode should run",
"opencodeBinarySelector.customPath.placeholder": "Enter path to opencode binary…",
"opencodeBinarySelector.actions.add": "Add",
"opencodeBinarySelector.actions.browse": "Browse for Binary…",
"opencodeBinarySelector.actions.removeTitle": "Remove binary",
"opencodeBinarySelector.badge.systemPath": "Use binary from system PATH",
"opencodeBinarySelector.status.checkingVersions": "Checking versions…",
"opencodeBinarySelector.status.checking": "Checking…",
"opencodeBinarySelector.dialog.title": "Select OpenCode Binary",
"opencodeBinarySelector.dialog.description": "Browse files exposed by the CLI server.",
"opencodeBinarySelector.validation.invalidBinary": "Invalid OpenCode binary",
"opencodeBinarySelector.validation.alreadyValidating": "Already validating",
"opencodeBinarySelector.display.systemPath": "{name} (system PATH)",
"opencodeBinarySelector.versionLabel": "v{version}",
} as const

View File

@@ -0,0 +1,17 @@
export const loadingScreenMessages = {
"loadingScreen.logoAlt": "CodeNomad logo",
"loadingScreen.status.issue": "Encountered an issue",
"loadingScreen.actions.showAnother": "Show another",
"loadingScreen.errors.missingRoot": "Loading root element not found",
"loadingScreen.phrases.neurons": "Warming up the AI neurons…",
"loadingScreen.phrases.daydreaming": "Convincing the AI to stop daydreaming…",
"loadingScreen.phrases.goggles": "Polishing the AIs code goggles…",
"loadingScreen.phrases.reorganizingFiles": "Asking the AI to stop reorganizing your files…",
"loadingScreen.phrases.coffee": "Feeding the AI additional coffee…",
"loadingScreen.phrases.nodeModules": "Teaching the AI not to delete node_modules (again)…",
"loadingScreen.phrases.actNatural": "Telling the AI to act natural before you arrive…",
"loadingScreen.phrases.rewritingHistory": "Asking the AI to please stop rewriting history…",
"loadingScreen.phrases.stretch": "Letting the AI stretch before its coding sprint…",
"loadingScreen.phrases.keyboardControl": "Persuading the AI to give you keyboard control…",
} as const

View File

@@ -0,0 +1,18 @@
export const logMessages = {
"logsView.title": "Server Logs",
"logsView.actions.show": "Show server logs",
"logsView.actions.hide": "Hide server logs",
"logsView.envVars.title": "Environment Variables ({count})",
"logsView.paused.title": "Server logs are paused",
"logsView.paused.description": "Enable streaming to watch your OpenCode server activity.",
"logsView.empty.waiting": "Waiting for server output...",
"logsView.scrollToBottom": "Scroll to bottom",
"infoView.logs.title": "Server Logs",
"infoView.logs.actions.show": "Show server logs",
"infoView.logs.actions.hide": "Hide server logs",
"infoView.logs.paused.title": "Server logs are paused",
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom",
} as const

View File

@@ -0,0 +1,7 @@
export const markdownMessages = {
"markdown.codeBlock.copy.label": "Copy",
"markdown.codeBlock.copy.copied": "Copied!",
"markdown.codeBlock.copy.failed": "Failed",
"markdown.copy": "Copy",
} as const

View File

@@ -0,0 +1,25 @@
export type MessageCatalog = Record<string, string>
type MergeParts<Parts extends readonly MessageCatalog[]> = Parts extends readonly [
infer Head extends MessageCatalog,
...infer Tail extends MessageCatalog[],
]
? Head & MergeParts<Tail>
: {}
export function mergeMessageParts<const Parts extends readonly MessageCatalog[]>(
...parts: Parts
): MergeParts<Parts> {
const result: Record<string, string> = Object.create(null)
for (const part of parts) {
for (const [key, value] of Object.entries(part)) {
if (key in result) {
throw new Error(`Duplicate i18n message key: ${key}`)
}
result[key] = value
}
}
return result as MergeParts<Parts>
}

View File

@@ -0,0 +1,109 @@
export const messagingMessages = {
"messageListHeader.sidebar.openSessionListAriaLabel": "Open session list",
"messageListHeader.metrics.usedLabel": "Used",
"messageListHeader.metrics.availableLabel": "Avail",
"messageListHeader.commandPalette.ariaLabel": "Open command palette",
"messageListHeader.commandPalette.button": "Command Palette",
"messageListHeader.connection.connected": "Connected",
"messageListHeader.connection.connecting": "Connecting...",
"messageListHeader.connection.disconnected": "Disconnected",
"messageSection.empty.logoAlt": "CodeNomad logo",
"messageSection.empty.brandTitle": "CodeNomad",
"messageSection.empty.title": "Start a conversation",
"messageSection.empty.description": "Type a message below or open the Command Palette:",
"messageSection.empty.tips.commandPalette": "Command Palette",
"messageSection.empty.tips.askAboutCodebase": "Ask about your codebase",
"messageSection.empty.tips.attachFilesPrefix": "Attach files with",
"messageSection.loading.messages": "Loading messages...",
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.quote.addAsQuote": "Add as quote",
"messageSection.quote.addAsCode": "Add as code",
"messageTimeline.ariaLabel": "Message timeline",
"messageTimeline.segment.user.label": "You",
"messageTimeline.segment.assistant.label": "Asst",
"messageTimeline.segment.compaction.label": "Compaction",
"messageTimeline.tool.fallbackLabel": "Tool Call",
"messageTimeline.tooltip.userFallback": "User message",
"messageTimeline.tooltip.assistantFallback": "Assistant response",
"messageTimeline.tooltip.compaction.auto": "Auto Compaction",
"messageTimeline.tooltip.compaction.manual": "User Compaction",
"messageTimeline.text.filePrefix": "[File] {filename}",
"messageTimeline.text.attachment": "Attachment",
"messageBlock.tool.header": "Tool Call",
"messageBlock.tool.unknown": "unknown",
"messageBlock.tool.goToSession.label": "Go to Session",
"messageBlock.tool.goToSession.title": "Go to session",
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
"messageBlock.compaction.ariaLabel": "Session compaction",
"messageBlock.compaction.autoLabel": "Session auto-compacted",
"messageBlock.compaction.manualLabel": "Session compacted by you",
"messageBlock.usage.input": "Input",
"messageBlock.usage.output": "Output",
"messageBlock.usage.reasoning": "Reasoning",
"messageBlock.usage.cacheRead": "Cache Read",
"messageBlock.usage.cacheWrite": "Cache Write",
"messageBlock.usage.cost": "Cost",
"messageBlock.step.agentLabel": "Agent: {agent}",
"messageBlock.step.modelLabel": "Model: {model}",
"messageBlock.reasoning.thinkingLabel": "Thinking",
"messageBlock.reasoning.expandAriaLabel": "Expand thinking",
"messageBlock.reasoning.collapseAriaLabel": "Collapse thinking",
"messageBlock.reasoning.indicator.hide": "Hide",
"messageBlock.reasoning.indicator.view": "View",
"messageBlock.reasoning.detailsAriaLabel": "Reasoning details",
"codeBlockInline.actions.copy": "Copy",
"codeBlockInline.actions.copied": "Copied!",
"messageItem.speaker.you": "You",
"messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revert",
"messageItem.actions.revertTitle": "Revert to this message",
"messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork from this message",
"messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!",
"messageItem.status.queued": "QUEUED",
"messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...",
"messageItem.status.failedToSend": "Message failed to send",
"messageItem.attachment.defaultName": "attachment",
"messageItem.attachment.downloadAriaLabel": "Download {name}",
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
"messageItem.agentMeta.modelLabel": "Model: {model}",
"messageItem.errors.authenticationFallback": "Authentication error",
"messageItem.errors.outputLengthExceeded": "Message output length exceeded",
"messageItem.errors.requestAborted": "Request was aborted",
"messageItem.errors.unknownFallback": "Unknown error occurred",
"attachmentChip.removeAriaLabel": "Remove attachment",
"expandButton.toggleAriaLabel": "Toggle chat input height",
"promptInput.placeholder.shell": "Run a shell command (Esc to exit)...",
"promptInput.placeholder.default": "Type your message, @file, @agent, or paste images and text...",
"promptInput.hints.shell.exit": "to exit shell mode",
"promptInput.hints.shell.enable": "Shell mode",
"promptInput.hints.commands": "Commands",
"promptInput.history.previousAriaLabel": "Previous prompt",
"promptInput.history.nextAriaLabel": "Next prompt",
"promptInput.overlay.newLine": "New line",
"promptInput.overlay.send": "Send",
"promptInput.overlay.filesAgents": "Files/agents",
"promptInput.overlay.history": "History",
"promptInput.overlay.attachments": "• {count} file(s) attached",
"promptInput.overlay.shellModeActive": "Shell mode active",
"promptInput.overlay.press": "Press",
"promptInput.overlay.againToAbort": "again to abort session",
"promptInput.stopSession.ariaLabel": "Stop session",
"promptInput.stopSession.title": "Stop session",
"promptInput.send.ariaLabel": "Send message",
"promptInput.send.errorFallback": "Failed to send message",
"promptInput.send.errorTitle": "Send failed",
} as const

View File

@@ -0,0 +1,51 @@
export const remoteAccessMessages = {
"remoteAccess.eyebrow": "Remote handover",
"remoteAccess.title": "Connect to CodeNomad remotely",
"remoteAccess.subtitle": "Use the addresses below to open CodeNomad from another device.",
"remoteAccess.close": "Close remote access",
"remoteAccess.refresh": "Refresh",
"remoteAccess.sections.listeningMode.label": "Listening mode",
"remoteAccess.sections.listeningMode.help": "Allow or limit remote handovers by binding to all interfaces or just localhost.",
"remoteAccess.toggle.on": "On",
"remoteAccess.toggle.off": "Off",
"remoteAccess.toggle.title": "Allow connections from other IPs",
"remoteAccess.toggle.caption.all": "Binding to 0.0.0.0",
"remoteAccess.toggle.caption.local": "Binding to 127.0.0.1",
"remoteAccess.toggle.note": "Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the server restarts.",
"remoteAccess.listeningMode.restartConfirm.message": "Restart to apply listening mode? This will stop all running instances.",
"remoteAccess.listeningMode.restartConfirm.title.all": "Open to other devices",
"remoteAccess.listeningMode.restartConfirm.title.local": "Limit to this device",
"remoteAccess.listeningMode.restartConfirm.confirmLabel": "Restart now",
"remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancel",
"remoteAccess.restart.errorManual": "Unable to restart automatically. Please restart the app to apply the change.",
"remoteAccess.sections.serverPassword.label": "Server password",
"remoteAccess.sections.serverPassword.help": "Remote handovers require a password. Set a memorable one to enable logins from other devices.",
"remoteAccess.authStatus.unavailable": "Authentication status unavailable.",
"remoteAccess.username": "Username: {username}",
"remoteAccess.password.status.set": "A password is set for remote access.",
"remoteAccess.password.status.unset": "No memorable password is set yet. Set one to allow remote handover logins.",
"remoteAccess.password.actions.cancel": "Cancel",
"remoteAccess.password.actions.change": "Change password",
"remoteAccess.password.actions.set": "Set password",
"remoteAccess.password.form.newPassword": "New password",
"remoteAccess.password.form.confirmPassword": "Confirm password",
"remoteAccess.password.form.placeholder": "At least 8 characters",
"remoteAccess.password.error.tooShort": "Password must be at least 8 characters.",
"remoteAccess.password.error.mismatch": "Passwords do not match.",
"remoteAccess.password.save.saving": "Saving…",
"remoteAccess.password.save.label": "Save password",
"remoteAccess.sections.addresses.label": "Reachable addresses",
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
"remoteAccess.addresses.loading": "Loading addresses…",
"remoteAccess.addresses.none": "No addresses available yet.",
"remoteAccess.address.scope.network": "Network",
"remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.internal": "Internal",
"remoteAccess.address.open": "Open",
"remoteAccess.address.showQr": "Show QR",
"remoteAccess.address.hideQr": "Hide QR",
"remoteAccess.address.qrAlt": "QR for {url}",
} as const

View File

@@ -0,0 +1,67 @@
export const sessionMessages = {
"sessionPicker.title": "OpenCode • {folder}",
"sessionPicker.empty.noPrevious": "No previous sessions",
"sessionPicker.resume.title": "Resume a session ({count}):",
"sessionPicker.session.untitled": "Untitled",
"sessionPicker.divider.or": "or",
"sessionPicker.new.title": "Start new session:",
"sessionPicker.agents.loading": "Loading agents...",
"sessionPicker.actions.creating": "Creating...",
"sessionPicker.actions.createSession": "Create Session",
"sessionPicker.actions.cancel": "Cancel",
"sessionList.header.title": "Sessions",
"sessionList.session.untitled": "Untitled",
"sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle",
"sessionList.status.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session",
"sessionList.expand.expandAriaLabel": "Expand session",
"sessionList.expand.collapseTitle": "Collapse",
"sessionList.expand.expandTitle": "Expand",
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
"sessionList.actions.copyId.title": "Copy session ID",
"sessionList.actions.rename.ariaLabel": "Rename session",
"sessionList.actions.rename.title": "Rename session",
"sessionList.actions.delete.ariaLabel": "Delete session",
"sessionList.actions.delete.title": "Delete session",
"sessionList.copyId.success": "Session ID copied",
"sessionList.copyId.error": "Unable to copy session ID",
"sessionList.delete.error": "Unable to delete session",
"sessionList.rename.error": "Unable to rename session",
"sessionRenameDialog.title": "Rename Session",
"sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".",
"sessionRenameDialog.description.default": "Set a new title for this session.",
"sessionRenameDialog.input.label": "Session name",
"sessionRenameDialog.input.placeholder": "Enter a session name",
"sessionRenameDialog.actions.cancel": "Cancel",
"sessionRenameDialog.actions.rename": "Rename",
"sessionRenameDialog.actions.renaming": "Renaming…",
"sessionView.fallback.sessionNotFound": "Session not found",
"sessionView.alerts.abortFailed.message": "Failed to stop session",
"sessionView.alerts.abortFailed.title": "Stop failed",
"sessionView.alerts.revertFailed.message": "Failed to revert to message",
"sessionView.alerts.revertFailed.title": "Revert failed",
"sessionView.alerts.forkFailed.message": "Failed to fork session",
"sessionView.alerts.forkFailed.title": "Fork failed",
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",
"sessionView.attachments.insertPastedTextTitle": "Insert pasted text",
"sessionView.attachments.removeAriaLabel": "Remove attachment",
"sessionEvents.sessionCompactedToast": "Session {label} was compacted",
"sessionEvents.sessionError.unknown": "Unknown error",
"sessionEvents.sessionError.title": "Session error",
"sessionEvents.sessionError.message": "Error: {message}",
"sessionState.cleanup.deepConfirm.message": "This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?",
"sessionState.cleanup.deepConfirm.title": "Deep Clean Sessions",
"sessionState.cleanup.deepConfirm.detail": "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.",
"sessionState.cleanup.deepConfirm.confirmLabel": "Continue",
"sessionState.cleanup.deepConfirm.cancelLabel": "Cancel",
"sessionState.cleanup.toast.one": "Cleaned up {count} blank session",
"sessionState.cleanup.toast.other": "Cleaned up {count} blank sessions",
} as const

View File

@@ -0,0 +1,54 @@
export const settingsMessages = {
"instanceServiceStatus.sections.lsp": "LSP Servers",
"instanceServiceStatus.sections.mcp": "MCP Servers",
"instanceServiceStatus.sections.plugins": "Plugins",
"instanceServiceStatus.lsp.loading": "Loading LSP servers...",
"instanceServiceStatus.lsp.empty": "No LSP servers detected.",
"instanceServiceStatus.lsp.status.connected": "Connected",
"instanceServiceStatus.lsp.status.error": "Error",
"instanceServiceStatus.mcp.loading": "Loading MCP servers...",
"instanceServiceStatus.mcp.empty": "No MCP servers detected.",
"instanceServiceStatus.mcp.toggleAriaLabel": "Toggle {name} MCP server",
"instanceServiceStatus.plugins.loading": "Loading plugins...",
"instanceServiceStatus.plugins.empty": "No plugins configured.",
"permissionBanner.pendingRequests.one": "{count} pending request",
"permissionBanner.pendingRequests.other": "{count} pending requests",
"permissionBanner.detail.permission.one": "{count} permission",
"permissionBanner.detail.permission.other": "{count} permissions",
"permissionBanner.detail.question.one": "{count} question",
"permissionBanner.detail.question.other": "{count} questions",
"permissionBanner.detail.wrapper": " ({detail})",
"agentSelector.placeholder": "Select agent...",
"agentSelector.badge.subagent": "subagent",
"agentSelector.none": "None",
"agentSelector.trigger.primary": "Agent: {agent}",
"modelSelector.placeholder.search": "Search models...",
"modelSelector.none": "None",
"modelSelector.trigger.primary": "Model: {model}",
"thinkingSelector.variant.default": "Default",
"thinkingSelector.label": "Thinking: {variant}",
"envEditor.title": "Environment Variables",
"envEditor.count.one": "({count} variable)",
"envEditor.count.other": "({count} variables)",
"envEditor.fields.name.placeholder": "Variable name",
"envEditor.fields.name.readOnlyTitle": "Variable name (read-only)",
"envEditor.fields.value.placeholder": "Variable value",
"envEditor.actions.remove.title": "Remove variable",
"envEditor.actions.add.title": "Add variable",
"envEditor.empty": "No environment variables configured. Add variables above to customize the OpenCode environment.",
"envEditor.help": "These variables will be available in the OpenCode environment when starting instances.",
"contextUsagePanel.headings.tokens": "Tokens",
"contextUsagePanel.headings.context": "Context",
"contextUsagePanel.labels.input": "Input",
"contextUsagePanel.labels.output": "Output",
"contextUsagePanel.labels.cost": "Cost",
"contextUsagePanel.labels.used": "Used",
"contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--",
} as const

View File

@@ -0,0 +1,6 @@
export const timeMessages = {
"time.relative.justNow": "just now",
"time.relative.daysAgoShort": "{count}d ago",
"time.relative.hoursAgoShort": "{count}h ago",
"time.relative.minutesAgoShort": "{count}m ago",
} as const

View File

@@ -0,0 +1,121 @@
export const toolCallMessages = {
"toolCall.pending.waitingToRun": "Waiting to run...",
"toolCall.error.label": "Error:",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
"toolCall.diff.viewMode.split": "Split",
"toolCall.diff.viewMode.unified": "Unified",
"toolCall.diagnostics.title": "Diagnostics",
"toolCall.diagnostics.ariaLabel": "Diagnostics",
"toolCall.diagnostics.ariaLabel.withLabel": "Diagnostics {label}",
"toolCall.diagnostics.severity.error.short": "ERR",
"toolCall.diagnostics.severity.warning.short": "WARN",
"toolCall.diagnostics.severity.info.short": "INFO",
"toolCall.renderer.toolName.shell": "Shell",
"toolCall.renderer.toolName.fetch": "Fetch",
"toolCall.renderer.toolName.invalid": "Invalid",
"toolCall.renderer.toolName.plan": "Plan",
"toolCall.renderer.toolName.applyPatch": "Apply patch",
"toolCall.renderer.action.working": "Working...",
"toolCall.renderer.action.writingCommand": "Writing command...",
"toolCall.renderer.action.preparingEdit": "Preparing edit...",
"toolCall.renderer.action.readingFile": "Reading file...",
"toolCall.renderer.action.preparingWrite": "Preparing write...",
"toolCall.renderer.action.preparingPatch": "Preparing patch...",
"toolCall.renderer.action.planning": "Planning...",
"toolCall.renderer.action.fetchingFromWeb": "Fetching from the web...",
"toolCall.renderer.action.findingFiles": "Finding files...",
"toolCall.renderer.action.searchingContent": "Searching content...",
"toolCall.renderer.action.listingDirectory": "Listing directory...",
"toolCall.renderer.bash.title.timeout": "Timeout: {timeout}",
"toolCall.renderer.read.detail.offset": "Offset: {offset}",
"toolCall.renderer.read.detail.limit": "Limit: {limit}",
"toolCall.renderer.todo.empty": "No plan items yet.",
"toolCall.renderer.todo.status.pending": "Pending",
"toolCall.renderer.todo.status.inProgress": "In progress",
"toolCall.renderer.todo.status.completed": "Completed",
"toolCall.renderer.todo.status.cancelled": "Cancelled",
"toolCall.renderer.todo.title.plan": "Plan",
"toolCall.renderer.todo.title.creating": "Creating plan",
"toolCall.renderer.todo.title.completing": "Completing plan",
"toolCall.renderer.todo.title.updating": "Updating plan",
"toolCall.permission.status.required": "Permission Required",
"toolCall.permission.status.queued": "Permission Queued",
"toolCall.permission.requestedDiff.label": "Requested diff",
"toolCall.permission.requestedDiff.withPath": "Requested diff · {path}",
"toolCall.permission.queuedText": "Waiting for earlier permission responses.",
"toolCall.permission.actions.allowOnce": "Allow Once",
"toolCall.permission.actions.alwaysAllow": "Always Allow",
"toolCall.permission.actions.deny": "Deny",
"toolCall.permission.shortcuts.allowOnce": "Allow once",
"toolCall.permission.shortcuts.alwaysAllow": "Always allow",
"toolCall.permission.shortcuts.deny": "Deny",
"toolCall.permission.errors.unableToUpdate": "Unable to update permission",
"permissionApproval.title": "Requests",
"permissionApproval.empty": "No pending requests.",
"permissionApproval.kind.permission": "Permission",
"permissionApproval.kind.question": "Question",
"permissionApproval.questionCount.one": "{count} question",
"permissionApproval.questionCount.other": "{count} questions",
"permissionApproval.status.active": "Active",
"permissionApproval.actions.closeAriaLabel": "Close",
"permissionApproval.actions.goToSession": "Go to Session",
"permissionApproval.actions.loadingSession": "Loading…",
"permissionApproval.actions.loadSession": "Load Session",
"permissionApproval.actions.allowOnce": "Allow Once",
"permissionApproval.actions.alwaysAllow": "Always Allow",
"permissionApproval.actions.deny": "Deny",
"permissionApproval.fallbackHint": "Load session for more information.",
"permissionApproval.errors.unableToUpdatePermission": "Unable to update permission",
"toolCall.question.status.required": "Question Required",
"toolCall.question.status.queued": "Question Queued",
"toolCall.question.status.questions": "Questions",
"toolCall.question.action.awaitingAnswers": "Awaiting answers...",
"toolCall.question.title.questions": "Questions",
"toolCall.question.title.askingQuestions": "Asking questions",
"toolCall.question.type.one": "Question",
"toolCall.question.type.other": "Questions",
"toolCall.question.number": "Q{number}:",
"toolCall.question.multiple": "Multiple",
"toolCall.question.custom.title": "Type a custom answer",
"toolCall.question.custom.label": "Custom answer",
"toolCall.question.custom.placeholder": "Type your own answer",
"toolCall.question.actions.submit": "Submit",
"toolCall.question.actions.dismiss": "Dismiss",
"toolCall.question.shortcuts.submit": "Submit",
"toolCall.question.shortcuts.dismiss": "Dismiss",
"toolCall.question.queuedText": "Waiting for earlier responses.",
"toolCall.question.validation.answerAll": "Please answer all questions before submitting.",
"toolCall.question.errors.unableToReply": "Unable to reply",
"toolCall.question.errors.unableToDismiss": "Unable to dismiss",
"toolCall.task.action.delegating": "Delegating...",
"toolCall.task.sections.prompt": "Prompt",
"toolCall.task.sections.steps": "Steps",
"toolCall.task.sections.output": "Output",
"toolCall.task.steps.count": "{count} steps",
"toolCall.task.meta.agentModel": "Agent: {agent} • Model: {model}",
"toolCall.task.meta.agent": "Agent: {agent}",
"toolCall.task.meta.model": "Model: {model}",
"toolCall.status.pending": "Pending",
"toolCall.status.running": "Running",
"toolCall.status.completed": "Completed",
"toolCall.status.error": "Error",
"toolCall.status.unknown": "Unknown",
"toolCall.applyPatch.action.preparing": "Preparing apply_patch...",
"toolCall.applyPatch.title.withFileCount.one": "{tool} ({count} file)",
"toolCall.applyPatch.title.withFileCount.other": "{tool} ({count} files)",
"toolCall.applyPatch.fileFallback": "File {number}",
} as const

View File

@@ -1,6 +1,7 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger"
import { tGlobal } from "./i18n"
const log = getLogger("actions")
@@ -259,19 +260,20 @@ function setupRenderer(isDark: boolean) {
// Use "text" as default when no language is specified
const resolvedLang = lang && lang.trim() ? lang.trim() : "text"
const escapedLang = escapeHtml(resolvedLang)
const copyLabel = escapeHtml(tGlobal("markdown.copy"))
const header = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
`.trim()
</svg>
<span class="copy-text">${copyLabel}</span>
</button>
</div>
`.trim()
if (highlightSuppressed) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`