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

@@ -18,6 +18,7 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { import {
hasInstances, hasInstances,
isSelectingFolder, isSelectingFolder,
@@ -51,6 +52,7 @@ const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme() const { isDark } = useTheme()
const { t } = useI18n()
const { const {
preferences, preferences,
recordWorkspaceLaunch, recordWorkspaceLaunch,
@@ -119,7 +121,7 @@ const App: Component = () => {
const formatLaunchErrorMessage = (error: unknown): string => { const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) { if (!error) {
return "Failed to launch workspace" return t("app.launchError.fallbackMessage")
} }
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try { try {
@@ -202,12 +204,12 @@ const App: Component = () => {
async function handleCloseInstance(instanceId: string) { async function handleCloseInstance(instanceId: string) {
const confirmed = await showConfirmDialog( const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.", t("app.stopInstance.confirmMessage"),
{ {
title: "Stop instance", title: t("app.stopInstance.title"),
variant: "warning", variant: "warning",
confirmLabel: "Stop", confirmLabel: t("app.stopInstance.confirmLabel"),
cancelLabel: "Keep running", cancelLabel: t("app.stopInstance.cancelLabel"),
}, },
) )
@@ -330,21 +332,20 @@ const App: Component = () => {
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6"> <Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div> <div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title> <Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words"> <Dialog.Description class="text-sm text-secondary mt-2 break-words">
We couldn't start the selected OpenCode binary. Review the error output below or choose a different {t("app.launchError.description")}
binary from Advanced Settings.
</Dialog.Description> </Dialog.Description>
</div> </div>
<div class="rounded-lg border border-base bg-surface-secondary p-4"> <div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p> <p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p> <p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div> </div>
<Show when={launchErrorMessage()}> <Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4"> <div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p> <p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre> <pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div> </div>
</Show> </Show>
@@ -356,11 +357,11 @@ const App: Component = () => {
class="selector-button selector-button-secondary" class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced} onClick={handleLaunchErrorAdvanced}
> >
Open Advanced Settings {t("app.launchError.openAdvancedSettings")}
</button> </button>
</Show> </Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}> <button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close {t("app.launchError.close")}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -430,7 +431,7 @@ const App: Component = () => {
clearLaunchError() clearLaunchError()
}} }}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Close (Esc)" title={t("app.launchError.closeTitle")}
> >
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import OpenCodeBinarySelector from "./opencode-binary-selector" import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor" import EnvironmentVariablesEditor from "./environment-variables-editor"
import { useI18n } from "../lib/i18n"
interface AdvancedSettingsModalProps { interface AdvancedSettingsModalProps {
open: boolean open: boolean
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
} }
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => { const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
const { t } = useI18n()
return ( return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}> <Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal> <Dialog.Portal>
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden"> <Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}> <header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title> <Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
</header> </header>
<div class="flex-1 overflow-y-auto p-6 space-y-6"> <div class="flex-1 overflow-y-auto p-6 space-y-6">
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h3 class="panel-title">Environment Variables</h3> <h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p> <p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} /> <EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
class="selector-button selector-button-secondary" class="selector-button selector-button-secondary"
onClick={props.onClose} onClick={props.onClose}
> >
Close {t("advancedSettings.actions.close")}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>

View File

@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions" import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import Kbd from "./kbd" import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -16,6 +17,7 @@ interface AgentSelectorProps {
} }
export default function AgentSelector(props: AgentSelectorProps) { export default function AgentSelector(props: AgentSelectorProps) {
const { t } = useI18n()
const instanceAgents = () => agents().get(props.instanceId) || [] const instanceAgents = () => agents().get(props.instanceId) || []
const session = createMemo(() => { const session = createMemo(() => {
@@ -72,7 +74,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
options={availableAgents()} options={availableAgents()}
optionValue="name" optionValue="name"
optionTextValue="name" optionTextValue="name"
placeholder="Select agent..." placeholder={t("agentSelector.placeholder")}
itemComponent={(itemProps) => ( itemComponent={(itemProps) => (
<Select.Item <Select.Item
item={itemProps.item} item={itemProps.item}
@@ -82,7 +84,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
<Select.ItemLabel class="selector-option-label flex items-center gap-2"> <Select.ItemLabel class="selector-option-label flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span> <span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}> <Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="neutral-badge">subagent</span> <span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
</Show> </Show>
</Select.ItemLabel> </Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}> <Show when={itemProps.item.rawValue.description}>
@@ -105,7 +107,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
{(state) => ( {(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked"> <div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
Agent: {state.selectedOption()?.name ?? "None"} {t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
</span> </span>
</div> </div>
)} )}

View File

@@ -2,28 +2,26 @@ import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js" import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts" import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts" import type { AlertVariant, AlertDialogState } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = { const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
info: { info: {
badgeBg: "var(--badge-neutral-bg)", badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)", badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)", badgeText: "var(--accent-primary)",
symbol: "i", symbol: "i",
fallbackTitle: "Heads up",
}, },
warning: { warning: {
badgeBg: "rgba(255, 152, 0, 0.14)", badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)", badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)", badgeText: "var(--status-warning)",
symbol: "!", symbol: "!",
fallbackTitle: "Please review",
}, },
error: { error: {
badgeBg: "var(--danger-soft-bg)", badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)", badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)", badgeText: "var(--status-error)",
symbol: "!", symbol: "!",
fallbackTitle: "Something went wrong",
}, },
} }
@@ -60,6 +58,7 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
} }
const AlertDialog: Component = () => { const AlertDialog: Component = () => {
const { t } = useI18n()
let primaryButtonRef: HTMLButtonElement | undefined let primaryButtonRef: HTMLButtonElement | undefined
let promptInputRef: HTMLInputElement | undefined let promptInputRef: HTMLInputElement | undefined
@@ -82,11 +81,25 @@ const AlertDialog: Component = () => {
{(payload) => { {(payload) => {
const variant = payload.variant ?? "info" const variant = payload.variant ?? "info"
const accent = variantAccent[variant] const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const fallbackTitle =
variant === "warning"
? t("alertDialog.fallbackTitle.warning")
: variant === "error"
? t("alertDialog.fallbackTitle.error")
: t("alertDialog.fallbackTitle.info")
const title = payload.title || fallbackTitle
const isConfirm = payload.type === "confirm" const isConfirm = payload.type === "confirm"
const isPrompt = payload.type === "prompt" const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK") const confirmLabel =
const cancelLabel = payload.cancelLabel || "Cancel" payload.confirmLabel ||
(isConfirm
? t("alertDialog.actions.confirm")
: isPrompt
? t("alertDialog.actions.run")
: t("alertDialog.actions.ok"))
const cancelLabel = payload.cancelLabel || t("alertDialog.actions.cancel")
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "") const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
@@ -127,7 +140,9 @@ const AlertDialog: Component = () => {
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label> <label class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input <input
ref={(el) => { ref={(el) => {
promptInputRef = el promptInputRef = el

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js" import { Component } from "solid-js"
import type { Attachment } from "../types/attachment" import type { Attachment } from "../types/attachment"
import { useI18n } from "../lib/i18n"
interface AttachmentChipProps { interface AttachmentChipProps {
attachment: Attachment attachment: Attachment
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
} }
const AttachmentChip: Component<AttachmentChipProps> = (props) => { const AttachmentChip: Component<AttachmentChipProps> = (props) => {
const { t } = useI18n()
return ( return (
<div <div
class="attachment-chip" class="attachment-chip"
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
<button <button
onClick={props.onRemove} onClick={props.onRemove}
class="attachment-remove" class="attachment-remove"
aria-label="Remove attachment" aria-label={t("attachmentChip.removeAriaLabel")}
> >
× ×
</button> </button>

View File

@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types" import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client" import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { useI18n } from "../lib/i18n"
interface BackgroundProcessOutputDialogProps { interface BackgroundProcessOutputDialogProps {
open: boolean open: boolean
@@ -12,6 +13,7 @@ interface BackgroundProcessOutputDialogProps {
} }
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) { export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const { t } = useI18n()
const [output, setOutput] = createSignal("") const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("") const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false) const [ansiEnabled, setAnsiEnabled] = createSignal(false)
@@ -67,7 +69,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
}) })
.catch(() => { .catch(() => {
if (!active) return if (!active) return
setRawOutput("Failed to load output.") setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
setAnsiEnabled(false) setAnsiEnabled(false)
setOutputHtml("") setOutputHtml("")
}) })
@@ -121,7 +123,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden"> <Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4"> <div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title> <Dialog.Title class="text-lg font-semibold text-primary">{t("backgroundProcessOutputDialog.title")}</Dialog.Title>
<Show when={props.process}> <Show when={props.process}>
<span class="text-xs text-secondary block"> <span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id} {props.process?.title} · {props.process?.id}
@@ -133,16 +135,16 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
</div> </div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}> <button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
Close {t("backgroundProcessOutputDialog.actions.close")}
</button> </button>
</div> </div>
<div class="flex-1 overflow-auto p-6"> <div class="flex-1 overflow-auto p-6">
<Show when={loading()}> <Show when={loading()}>
<p class="text-xs text-secondary">Loading output...</p> <p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
</Show> </Show>
<Show when={!loading()}> <Show when={!loading()}>
<Show when={truncated()}> <Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p> <p class="text-xs text-secondary mb-2">{t("backgroundProcessOutputDialog.truncatedNotice")}</p>
</Show> </Show>
<Show <Show
when={ansiEnabled()} when={ansiEnabled()}

View File

@@ -3,6 +3,7 @@ import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown" import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const inlineLoadedLanguages = new Set<string>() const inlineLoadedLanguages = new Set<string>()
@@ -15,6 +16,7 @@ interface CodeBlockInlineProps {
} }
export function CodeBlockInline(props: CodeBlockInlineProps) { export function CodeBlockInline(props: CodeBlockInlineProps) {
const { t } = useI18n()
const { isDark } = useTheme() const { isDark } = useTheme()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
@@ -97,8 +99,8 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg> </svg>
<span class="copy-text"> <span class="copy-text">
<Show when={copied()} fallback="Copy"> <Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
Copied! {t("codeBlockInline.actions.copied")}
</Show> </Show>
</span> </span>
</button> </button>

View File

@@ -1,7 +1,8 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js" import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import type { Command } from "../lib/commands" import { resolveResolvable, type Command } from "../lib/commands"
import Kbd from "./kbd" import Kbd from "./kbd"
import { useI18n } from "../lib/i18n"
interface CommandPaletteProps { interface CommandPaletteProps {
open: boolean open: boolean
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
} }
const CommandPalette: Component<CommandPaletteProps> = (props) => { const CommandPalette: Component<CommandPaletteProps> = (props) => {
const { t } = useI18n()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null) const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false) const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
@@ -32,6 +34,27 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
const categoryLabel = (category: string) => {
switch (category) {
case "Custom Commands":
return t("commandPalette.category.customCommands")
case "Instance":
return t("commandPalette.category.instance")
case "Session":
return t("commandPalette.category.session")
case "Agent & Model":
return t("commandPalette.category.agentModel")
case "Input & Focus":
return t("commandPalette.category.inputFocus")
case "System":
return t("commandPalette.category.system")
case "Other":
return t("commandPalette.category.other")
default:
return category
}
}
type CommandGroup = { category: string; commands: Command[]; startIndex: number } type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] } type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const filtered = q const filtered = q
? source.filter((cmd) => { ? source.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(q) const labelMatch = label.toLowerCase().includes(q)
const descMatch = cmd.description.toLowerCase().includes(q) const descMatch = description.toLowerCase().includes(q)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q)) const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = cmd.category?.toLowerCase().includes(q) const categoryMatch = category?.toLowerCase().includes(q)
return labelMatch || descMatch || keywordMatch || categoryMatch return labelMatch || descMatch || keywordMatch || categoryMatch
}) })
: source : source
const groupsMap = new Map<string, Command[]>() const groupsMap = new Map<string, Command[]>()
for (const cmd of filtered) { for (const cmd of filtered) {
const category = cmd.category || "Other" const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
const list = groupsMap.get(category) const list = groupsMap.get(category)
if (list) { if (list) {
list.push(cmd) list.push(cmd)
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"> <div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content <Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]" class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<Dialog.Title class="sr-only">Command Palette</Dialog.Title> <Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description> <Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
<div class="modal-search-container"> <div class="modal-search-container">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
setQuery(e.currentTarget.value) setQuery(e.currentTarget.value)
setSelectedCommandId(null) setSelectedCommandId(null)
}} }}
placeholder="Type a command or search..." placeholder={t("commandPalette.searchPlaceholder")}
class="modal-search-input" class="modal-search-input"
/> />
</div> </div>
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
> >
<Show <Show
when={orderedCommands().length > 0} when={orderedCommands().length > 0}
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>} fallback={<div class="modal-empty-state">{t("commandPalette.empty", { query: query() })}</div>}
> >
<For each={groupedCommandList()}> <For each={groupedCommandList()}>
{(group) => ( {(group) => (
<div class="py-2"> <div class="py-2">
<div class="modal-section-header"> <div class="modal-section-header">
{group.category} {categoryLabel(group.category)}
</div> </div>
<For each={group.commands}> <For each={group.commands}>
{(command, localIndex) => { {(command, localIndex) => {
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="modal-item-label"> <div class="modal-item-label">
{typeof command.label === "function" ? command.label() : command.label} {resolveResolvable(command.label)}
</div> </div>
<div class="modal-item-description"> <div class="modal-item-description">
{command.description} {resolveResolvable(command.description)}
</div> </div>
</div> </div>
<Show when={command.shortcut}> <Show when={command.shortcut}>

View File

@@ -4,6 +4,7 @@ import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { showAlertDialog, showPromptDialog } from "../stores/alerts" import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
function normalizePathKey(input?: string | null) { function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") { if (!input || input === "." || input === "./") {
@@ -62,6 +63,7 @@ type FolderRow =
| { type: "folder"; entry: FileSystemEntry } | { type: "folder"; entry: FileSystemEntry }
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => { const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("") const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
@@ -110,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory() const metadata = await loadDirectory()
applyMetadata(metadata) applyMetadata(metadata)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem" const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message) setError(message)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -200,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory(path) const metadata = await loadDirectory(path)
applyMetadata(metadata) applyMetadata(metadata)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem" const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message) setError(message)
} }
} }
@@ -266,19 +268,19 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
} }
const name = const name =
(await showPromptDialog("Create a new folder in the current directory.", { (await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
title: "New Folder", title: t("directoryBrowser.createFolder.title"),
inputLabel: "Folder name", inputLabel: t("directoryBrowser.createFolder.inputLabel"),
inputPlaceholder: "e.g. my-new-project", inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
confirmLabel: "Create", confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
cancelLabel: "Cancel", cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
}))?.trim() ?? "" }))?.trim() ?? ""
if (!name) return if (!name) return
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) { if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
showAlertDialog("Please enter a single folder name.", { showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
variant: "warning", variant: "warning",
detail: "Folder names cannot include slashes, '..', or '~'.", detail: t("directoryBrowser.createFolder.invalidNameDetail"),
}) })
return return
} }
@@ -297,8 +299,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name) const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
await navigateTo(created.path) await navigateTo(created.path)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unable to create folder" const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
showAlertDialog(message, { variant: "error", title: "Unable to create folder" }) showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
} finally { } finally {
setCreatingFolder(false) setCreatingFolder(false)
} }
@@ -323,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-heading"> <div class="directory-browser-heading">
<h3 class="directory-browser-title">{props.title}</h3> <h3 class="directory-browser-title">{props.title}</h3>
<p class="directory-browser-description"> <p class="directory-browser-description">
{props.description || "Browse folders under the configured workspace root."} {props.description || t("directoryBrowser.defaultDescription")}
</p> </p>
</div> </div>
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}> <button type="button" class="directory-browser-close" aria-label={t("directoryBrowser.close")} onClick={props.onClose}>
<X class="w-5 h-5" /> <X class="w-5 h-5" />
</button> </button>
</div> </div>
@@ -335,7 +337,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={rootPath()}> <Show when={rootPath()}>
<div class="directory-browser-current"> <div class="directory-browser-current">
<div class="directory-browser-current-meta"> <div class="directory-browser-current-meta">
<span class="directory-browser-current-label">Current folder</span> <span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span> <span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div> </div>
<div class="directory-browser-current-actions"> <div class="directory-browser-current-actions">
@@ -350,7 +352,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
} }
}} }}
> >
Select Current {t("directoryBrowser.selectCurrent")}
</button> </button>
<button <button
type="button" type="button"
@@ -360,7 +362,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<FolderPlus class="w-4 h-4" /> <FolderPlus class="w-4 h-4" />
{creatingFolder() ? "Creating" : "New Folder"} {creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
</span> </span>
</button> </button>
</div> </div>
@@ -373,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}> <Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
<div class="directory-browser-loading"> <div class="directory-browser-loading">
<Loader2 class="w-5 h-5 animate-spin" /> <Loader2 class="w-5 h-5 animate-spin" />
<span>Loading folders</span> <span>{t("directoryBrowser.loadingFolders")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -381,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
> >
<Show <Show
when={folderRows().length > 0} when={folderRows().length > 0}
fallback={<div class="panel-empty-state flex-1">No folders available.</div>} fallback={<div class="panel-empty-state flex-1">{t("directoryBrowser.noFolders")}</div>}
> >
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox"> <div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}> <For each={folderRows()}>
{(item) => { {(item) => {
const isFolder = item.type === "folder" const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : "Up one level" const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp()) const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
return ( return (
<div class="panel-list-item" role="option"> <div class="panel-list-item" role="option">
@@ -414,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
handleEntrySelect(item.entry) handleEntrySelect(item.entry)
}} }}
> >
Select {t("directoryBrowser.select")}
</button> </button>
) : null} ) : null}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js" import { Component } from "solid-js"
import { Loader2 } from "lucide-solid" import { Loader2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -9,15 +10,19 @@ interface EmptyStateProps {
} }
const EmptyState: Component<EmptyStateProps> = (props) => { const EmptyState: Component<EmptyStateProps> = (props) => {
const { t } = useI18n()
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
const shortcut = `${modifier}+N`
return ( return (
<div class="flex h-full w-full items-center justify-center bg-surface-secondary"> <div class="flex h-full w-full items-center justify-center bg-surface-secondary">
<div class="max-w-[500px] px-8 py-12 text-center"> <div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center"> <div class="mb-8 flex justify-center">
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" /> <img src={codeNomadIcon} alt={t("emptyState.logoAlt")} class="h-24 w-auto" loading="lazy" />
</div> </div>
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p> <p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
<button <button
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
{props.isLoading ? ( {props.isLoading ? (
<> <>
<Loader2 class="h-4 w-4 animate-spin" /> <Loader2 class="h-4 w-4 animate-spin" />
Selecting... {t("emptyState.actions.selecting")}
</> </>
) : ( ) : (
"Select Folder" t("emptyState.actions.selectFolder")
)} )}
</button> </button>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N {t("emptyState.keyboardShortcut", { shortcut })}
</p> </p>
<div class="mt-6 space-y-1 text-sm text-muted"> <div class="mt-6 space-y-1 text-sm text-muted">
<p>Examples: ~/projects/my-app</p> <p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
<p>You can have multiple instances of the same folder</p> <p>{t("emptyState.multipleInstances")}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,14 @@
import { Component, createSignal, For, Show } from "solid-js" import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid" import { Plus, Trash2, Key, Globe } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
interface EnvironmentVariablesEditorProps { interface EnvironmentVariablesEditorProps {
disabled?: boolean disabled?: boolean
} }
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => { const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n()
const { const {
preferences, preferences,
addEnvironmentVariable, addEnvironmentVariable,
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<Globe class="w-4 h-4 icon-muted" /> <Globe class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Environment Variables</span> <span class="text-sm font-medium text-secondary">{t("envEditor.title")}</span>
<span class="text-xs text-muted"> <span class="text-xs text-muted">
({entries().length} variable{entries().length !== 1 ? "s" : ""}) {entries().length === 1
? t("envEditor.count.one", { count: entries().length })
: t("envEditor.count.other", { count: entries().length })}
</span> </span>
</div> </div>
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
value={key} value={key}
disabled={props.disabled} disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed" class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
placeholder="Variable name" placeholder={t("envEditor.fields.name.placeholder")}
title="Variable name (read-only)" title={t("envEditor.fields.name.readOnlyTitle")}
/> />
<input <input
type="text" type="text"
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
disabled={props.disabled} disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)} onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed" class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value" placeholder={t("envEditor.fields.value.placeholder")}
/> />
</div> </div>
<button <button
onClick={() => handleRemoveVariable(key)} onClick={() => handleRemoveVariable(key)}
disabled={props.disabled} disabled={props.disabled}
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors" class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Remove variable" title={t("envEditor.actions.remove.title")}
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</button> </button>
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={props.disabled} disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed" class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable name" placeholder={t("envEditor.fields.name.placeholder")}
/> />
<input <input
type="text" type="text"
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={props.disabled} disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed" class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value" placeholder={t("envEditor.fields.value.placeholder")}
/> />
</div> </div>
<button <button
onClick={handleAddVariable} onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()} disabled={props.disabled || !newKey().trim()}
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors" class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Add variable" title={t("envEditor.actions.add.title")}
> >
<Plus class="w-3.5 h-3.5" /> <Plus class="w-3.5 h-3.5" />
</button> </button>
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<Show when={entries().length === 0}> <Show when={entries().length === 0}>
<div class="text-xs text-muted text-center py-2"> <div class="text-xs text-muted text-center py-2">
No environment variables configured. Add variables above to customize the OpenCode environment. {t("envEditor.empty")}
</div> </div>
</Show> </Show>
<div class="text-xs text-muted mt-2"> <div class="text-xs text-muted mt-2">
These variables will be available in the OpenCode environment when starting instances. {t("envEditor.help")}
</div> </div>
</div> </div>
) )

View File

@@ -1,5 +1,6 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid" import { Maximize2, Minimize2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface ExpandButtonProps { interface ExpandButtonProps {
expandState: () => "normal" | "expanded" expandState: () => "normal" | "expanded"
@@ -7,6 +8,8 @@ interface ExpandButtonProps {
} }
export default function ExpandButton(props: ExpandButtonProps) { export default function ExpandButton(props: ExpandButtonProps) {
const { t } = useI18n()
function handleClick() { function handleClick() {
const current = props.expandState() const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal") props.onToggleExpand(current === "normal" ? "expanded" : "normal")
@@ -17,7 +20,7 @@ export default function ExpandButton(props: ExpandButtonProps) {
type="button" type="button"
class="prompt-expand-button" class="prompt-expand-button"
onClick={handleClick} onClick={handleClick}
aria-label="Toggle chat input height" aria-label={t("expandButton.toggleAriaLabel")}
> >
<Show <Show
when={props.expandState() === "normal"} when={props.expandState() === "normal"}

View File

@@ -3,6 +3,7 @@ import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("actions") const log = getLogger("actions")
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry } type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => { const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("") const [rootPath, setRootPath] = createSignal("")
const [entries, setEntries] = createSignal<FileSystemEntry[]>([]) const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null) const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
setRootPath(metadata.rootPath) setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? []) setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem" const message = err instanceof Error ? err.message : t("filesystemBrowser.errors.loadFilesystemFallback")
setError(message) setError(message)
} }
} }
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function describeLoadingPath() { function describeLoadingPath() {
const path = loadingPath() const path = loadingPath()
if (!path) { if (!path) {
return "filesystem" return t("filesystemBrowser.loading.filesystem")
} }
if (path === ".") { if (path === ".") {
return rootPath() || "workspace root" return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
} }
return resolveAbsolutePath(rootPath(), path) return resolveAbsolutePath(rootPath(), path)
} }
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function handleNavigateTo(path: string) { function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => { void fetchDirectory(path, true).catch((err) => {
log.error("Failed to open directory", err) log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory") setError(err instanceof Error ? err.message : t("filesystemBrowser.errors.openDirectoryFallback"))
}) })
} }
@@ -277,19 +279,21 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="panel-header flex items-start justify-between gap-4"> <div class="panel-header flex items-start justify-between gap-4">
<div> <div>
<h3 class="panel-title">{props.title}</h3> <h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p> <p class="panel-subtitle">{props.description || t("filesystemBrowser.descriptionFallback")}</p>
<Show when={rootPath()}> <Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p> <p class="text-xs text-muted mt-1 font-mono break-all">
{t("filesystemBrowser.rootLabel", { root: rootPath() })}
</p>
</Show> </Show>
</div> </div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}> <button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" /> <X class="w-4 h-4" />
Close {t("filesystemBrowser.actions.close")}
</button> </button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label> <label class="w-full text-sm text-secondary mb-2 block">{t("filesystemBrowser.filterLabel")}</label>
<div class="selector-input-group"> <div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted"> <div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" /> <Search class="w-4 h-4" />
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
type="text" type="text"
value={searchQuery()} value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)} onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"} placeholder={
props.mode === "directories"
? t("filesystemBrowser.search.placeholder.directories")
: t("filesystemBrowser.search.placeholder.files")
}
class="selector-input" class="selector-input"
/> />
</div> </div>
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="px-4 pb-2"> <div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3"> <div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div> <div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p> <p class="text-xs text-secondary uppercase tracking-wide">{t("filesystemBrowser.currentFolder.label")}</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p> <p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div> </div>
<button <button
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
class="selector-button selector-button-secondary whitespace-nowrap" class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())} onClick={() => props.onSelect(currentAbsolutePath())}
> >
Select Current {t("filesystemBrowser.currentFolder.selectCurrent")}
</button> </button>
</div> </div>
</div> </div>
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" /> <Loader2 class="w-4 h-4 animate-spin" />
<span>Loading {describeLoadingPath()}</span> <span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<Show when={loadingPath()}> <Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary"> <div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" /> <Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span> <span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div> </div>
</Show> </Show>
<Show <Show
when={folderRows().length > 0} when={folderRows().length > 0}
fallback={ fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary"> <div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No entries found.</p> <p>{t("filesystemBrowser.empty.noEntries")}</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}> <button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry {t("filesystemBrowser.actions.retry")}
</button> </button>
</div> </div>
} }
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<ArrowUpLeft class="w-4 h-4" /> <ArrowUpLeft class="w-4 h-4" />
</div> </div>
<div class="directory-browser-row-text"> <div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span> <span class="directory-browser-row-name">{t("filesystemBrowser.navigation.upOneLevel")}</span>
</div> </div>
</button> </button>
</div> </div>
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
selectEntry() selectEntry()
}} }}
> >
Select {t("filesystemBrowser.actions.select")}
</button> </button>
</div> </div>
</div> </div>
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>
<span>Navigate</span> <span>{t("filesystemBrowser.hints.navigate")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd> <kbd class="kbd">Enter</kbd>
<span>Select</span> <span>{t("filesystemBrowser.hints.select")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd> <kbd class="kbd">Esc</kbd>
<span>Close</span> <span>{t("filesystemBrowser.hints.close")}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
} }
export default FileSystemBrowserDialog export default FileSystemBrowserDialog

View File

@@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info" import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
interface InfoViewProps { interface InfoViewProps {
instanceId: string instanceId: string
@@ -10,6 +11,7 @@ interface InfoViewProps {
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>() const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const InfoView: Component<InfoViewProps> = (props) => { const InfoView: Component<InfoViewProps> = (props) => {
const { t } = useI18n()
let scrollRef: HTMLDivElement | undefined let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId) const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
@@ -90,18 +92,18 @@ const InfoView: Component<InfoViewProps> = (props) => {
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden"> <div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="log-header"> <div class="log-header">
<h2 class="panel-title">Server Logs</h2> <h2 class="panel-title">{t("infoView.logs.title")}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show <Show
when={streamingEnabled()} when={streamingEnabled()}
fallback={ fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}> <button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs {t("infoView.logs.actions.show")}
</button> </button>
} }
> >
<button type="button" class="button-tertiary" onClick={handleDisableLogs}> <button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs {t("infoView.logs.actions.hide")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -116,17 +118,17 @@ const InfoView: Component<InfoViewProps> = (props) => {
when={streamingEnabled()} when={streamingEnabled()}
fallback={ fallback={
<div class="log-paused-state"> <div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p> <p class="log-paused-title">{t("infoView.logs.paused.title")}</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p> <p class="log-paused-description">{t("infoView.logs.paused.description")}</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}> <button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs {t("infoView.logs.actions.show")}
</button> </button>
</div> </div>
} }
> >
<Show <Show
when={logs().length > 0} when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>} fallback={<div class="log-empty-state">{t("infoView.logs.empty.waiting")}</div>}
> >
<For each={logs()}> <For each={logs()}>
{(entry) => ( {(entry) => (
@@ -148,7 +150,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
class="scroll-to-bottom" class="scroll-to-bottom"
> >
<ChevronDown class="w-4 h-4" /> <ChevronDown class="w-4 h-4" />
Scroll to bottom {t("infoView.logs.scrollToBottom")}
</button> </button>
</Show> </Show>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { useI18n } from "../lib/i18n"
interface InstanceDisconnectedModalProps { interface InstanceDisconnectedModalProps {
open: boolean open: boolean
@@ -8,8 +9,10 @@ interface InstanceDisconnectedModalProps {
} }
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) { export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
const folderLabel = props.folder || "this workspace" const { t } = useI18n()
const reasonLabel = props.reason || "The server stopped responding"
const folderLabel = () => props.folder || t("instanceDisconnected.folderFallback")
const reasonLabel = () => props.reason || t("instanceDisconnected.reasonFallback")
return ( return (
<Dialog open={props.open} modal> <Dialog open={props.open} modal>
@@ -18,25 +21,25 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6"> <Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div> <div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title> <Dialog.Title class="text-xl font-semibold text-primary">{t("instanceDisconnected.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words"> <Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working. {t("instanceDisconnected.description", { folder: folderLabel() })}
</Dialog.Description> </Dialog.Description>
</div> </div>
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary"> <div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
<p class="font-medium text-primary">Details</p> <p class="font-medium text-primary">{t("instanceDisconnected.details.title")}</p>
<p class="mt-2 text-secondary">{reasonLabel}</p> <p class="mt-2 text-secondary">{reasonLabel()}</p>
{props.folder && ( {props.folder && (
<p class="mt-2 text-secondary"> <p class="mt-2 text-secondary">
Folder: <span class="font-mono text-primary break-all">{props.folder}</span> {t("instanceDisconnected.details.folderLabel")} <span class="font-mono text-primary break-all">{props.folder}</span>
</p> </p>
)} )}
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}> <button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
Close Instance {t("instanceDisconnected.actions.closeInstance")}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>

View File

@@ -2,6 +2,7 @@ import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status" import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
interface InstanceInfoProps { interface InstanceInfoProps {
instance: Instance instance: Instance
@@ -9,6 +10,7 @@ interface InstanceInfoProps {
} }
const InstanceInfo: Component<InstanceInfoProps> = (props) => { const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext() const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false) const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
@@ -26,11 +28,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return ( return (
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2 class="panel-title">Instance Information</h2> <h2 class="panel-title">{t("instanceInfo.title")}</h2>
</div> </div>
<div class="panel-body space-y-3"> <div class="panel-body space-y-3">
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base"> <div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder} {currentInstance().folder}
</div> </div>
@@ -41,7 +43,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<> <>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Project {t("instanceInfo.labels.project")}
</div> </div>
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary"> <div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id} {project().id}
@@ -51,7 +53,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={project().vcs}> <Show when={project().vcs}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Version Control {t("instanceInfo.labels.versionControl")}
</div> </div>
<div class="flex items-center gap-2 text-xs text-primary"> <div class="flex items-center gap-2 text-xs text-primary">
<svg <svg
@@ -73,7 +75,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={binaryVersion()}> <Show when={binaryVersion()}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version {t("instanceInfo.labels.opencodeVersion")}
</div> </div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{binaryVersion()} v{binaryVersion()}
@@ -84,7 +86,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={currentInstance().binaryPath}> <Show when={currentInstance().binaryPath}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path {t("instanceInfo.labels.binaryPath")}
</div> </div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath} {currentInstance().binaryPath}
@@ -95,7 +97,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={environmentEntries().length > 0}> <Show when={environmentEntries().length > 0}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({environmentEntries().length}) {t("instanceInfo.labels.environmentVariables", { count: environmentEntries().length })}
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<For each={environmentEntries()}> <For each={environmentEntries()}>
@@ -127,24 +129,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
Loading... {t("instanceInfo.loading")}
</div> </div>
</div> </div>
</Show> </Show>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">{t("instanceInfo.server.title")}</div>
<div class="space-y-1 text-xs"> <div class="space-y-1 text-xs">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">Port:</span> <span class="text-secondary">{t("instanceInfo.server.port")}</span>
<span class="text-primary font-mono">{currentInstance().port}</span> <span class="text-primary font-mono">{currentInstance().port}</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">PID:</span> <span class="text-secondary">{t("instanceInfo.server.pid")}</span>
<span class="text-primary font-mono">{currentInstance().pid}</span> <span class="text-primary font-mono">{currentInstance().pid}</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-secondary">Status:</span> <span class="text-secondary">{t("instanceInfo.server.status")}</span>
<span class={`status-badge ${currentInstance().status}`}> <span class={`status-badge ${currentInstance().status}`}>
<div <div
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`} class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}

View File

@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch" import Switch from "@suid/material/Switch"
import type { Instance, RawMcpStatus } from "../types/instance" import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("session") const log = getLogger("session")
@@ -42,6 +43,7 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
} }
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => { const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext() const metadataContext = useOptionalInstanceMetadataContext()
const instance = metadataContext?.instance ?? (() => { const instance = metadataContext?.instance ?? (() => {
if (props.initialInstance) { if (props.initialInstance) {
@@ -112,12 +114,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5"> <section class="space-y-1.5">
<Show when={showHeadings()}> <Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide"> <div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers {t("instanceServiceStatus.sections.lsp")}
</div> </div>
</Show> </Show>
<Show <Show
when={!isLspLoading() && lspServers().length > 0} when={!isLspLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")} fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))}
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<For each={lspServers()}> <For each={lspServers()}>
@@ -132,7 +134,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
</div> </div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary"> <div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} /> <div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span> <span>
{server.status === "connected"
? t("instanceServiceStatus.lsp.status.connected")
: t("instanceServiceStatus.lsp.status.error")}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -147,12 +153,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5"> <section class="space-y-1.5">
<Show when={showHeadings()}> <Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide"> <div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers {t("instanceServiceStatus.sections.mcp")}
</div> </div>
</Show> </Show>
<Show <Show
when={!isMcpLoading() && mcpServers().length > 0} when={!isMcpLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")} fallback={renderEmptyState(isMcpLoading() ? t("instanceServiceStatus.mcp.loading") : t("instanceServiceStatus.mcp.empty"))}
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<For each={mcpServers()}> <For each={mcpServers()}>
@@ -192,7 +198,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
disabled={switchDisabled()} disabled={switchDisabled()}
color="success" color="success"
size="small" size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }} inputProps={{ "aria-label": t("instanceServiceStatus.mcp.toggleAriaLabel", { name: server.name }) }}
onChange={(_, checked) => { onChange={(_, checked) => {
if (switchDisabled()) return if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked)) void toggleMcpServer(server.name, Boolean(checked))
@@ -222,12 +228,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5"> <section class="space-y-1.5">
<Show when={showHeadings()}> <Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide"> <div class="text-xs font-medium text-muted uppercase tracking-wide">
Plugins {t("instanceServiceStatus.sections.plugins")}
</div> </div>
</Show> </Show>
<Show <Show
when={!isPluginsLoading() && plugins().length > 0} when={!isPluginsLoading() && plugins().length > 0}
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")} fallback={renderEmptyState(isPluginsLoading() ? t("instanceServiceStatus.plugins.loading") : t("instanceServiceStatus.plugins.empty"))}
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<For each={plugins()}> <For each={plugins()}>

View File

@@ -2,6 +2,7 @@ import { Component, createMemo } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status" import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid" import { FolderOpen, ShieldAlert, X } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface InstanceTabProps { interface InstanceTabProps {
instance: Instance instance: Instance
@@ -27,6 +28,7 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
} }
const InstanceTab: Component<InstanceTabProps> = (props) => { const InstanceTab: Component<InstanceTabProps> = (props) => {
const { t } = useI18n()
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id)) const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const statusClassName = createMemo(() => { const statusClassName = createMemo(() => {
const status = aggregatedStatus() const status = aggregatedStatus()
@@ -35,13 +37,13 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
const statusTitle = createMemo(() => { const statusTitle = createMemo(() => {
switch (aggregatedStatus()) { switch (aggregatedStatus()) {
case "permission": case "permission":
return "Waiting on permission" return t("instanceTab.status.permission")
case "compacting": case "compacting":
return "Compacting" return t("instanceTab.status.compacting")
case "working": case "working":
return "Working" return t("instanceTab.status.working")
default: default:
return "Idle" return t("instanceTab.status.idle")
} }
}) })
@@ -61,7 +63,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
<span <span
class={`status-indicator session-status ml-auto ${statusClassName()}`} class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle()} title={statusTitle()}
aria-label={`Instance status: ${statusTitle()}`} aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
> >
{aggregatedStatus() === "permission" ? ( {aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
@@ -77,7 +79,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Close instance" aria-label={t("instanceTab.actions.close.ariaLabel")}
> >
<X class="w-3 h-3" /> <X class="w-3 h-3" />
</span> </span>

View File

@@ -4,6 +4,7 @@ import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp } from "lucide-solid" import { Plus, MonitorUp } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
interface InstanceTabsProps { interface InstanceTabsProps {
instances: Map<string, Instance> instances: Map<string, Instance>
@@ -15,6 +16,7 @@ interface InstanceTabsProps {
} }
const InstanceTabs: Component<InstanceTabsProps> = (props) => { const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
return ( return (
<div class="tab-bar tab-bar-instance"> <div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist"> <div class="tab-container" role="tablist">
@@ -34,8 +36,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<button <button
class="new-tab-button" class="new-tab-button"
onClick={props.onNew} onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)" title={t("instanceTabs.new.title")}
aria-label="New instance" aria-label={t("instanceTabs.new.ariaLabel")}
> >
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</button> </button>
@@ -54,8 +56,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<button <button
class="new-tab-button tab-remote-button" class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()} onClick={() => props.onOpenRemoteAccess?.()}
title="Remote connect" title={t("instanceTabs.remote.title")}
aria-label="Remote connect" aria-label={t("instanceTabs.remote.ariaLabel")}
> >
<MonitorUp class="w-4 h-4" /> <MonitorUp class="w-4 h-4" />
</button> </button>

View File

@@ -9,6 +9,7 @@ import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry" import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils" import { isMac } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -19,6 +20,7 @@ interface InstanceWelcomeViewProps {
} }
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => { const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const { t } = useI18n()
const [isCreating, setIsCreating] = createSignal(false) const [isCreating, setIsCreating] = createSignal(false)
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions") const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
@@ -47,7 +49,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
ctrl: !isMac(), ctrl: !isMac(),
}, },
handler: () => {}, handler: () => {},
description: "New Session", description: t("instanceWelcome.shortcuts.newSession"),
context: "global", context: "global",
} }
}) })
@@ -248,10 +250,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago` if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return `${hours}h ago` if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return `${minutes}m ago` if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return "just now" return t("time.relative.justNow")
} }
function formatTimestamp(timestamp: number): string { function formatTimestamp(timestamp: number): string {
@@ -291,7 +293,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
setRenameTarget(null) setRenameTarget(null)
} catch (error) { } catch (error) {
log.error("Failed to rename session:", error) log.error("Failed to rename session:", error)
showToastNotification({ message: "Unable to rename session", variant: "error" }) showToastNotification({ message: t("instanceWelcome.toasts.renameError"), variant: "error" })
} finally { } finally {
setIsRenaming(false) setIsRenaming(false)
} }
@@ -333,11 +335,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
/> />
</svg> </svg>
</div> </div>
<p class="panel-empty-state-title">No Previous Sessions</p> <p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
<p class="panel-empty-state-description">Create a new session below to get started</p> <p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}> <Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}> <button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
View Instance Info {t("instanceWelcome.actions.viewInstanceInfo")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -347,8 +349,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel-empty-state-icon"> <div class="panel-empty-state-icon">
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" /> <Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
</div> </div>
<p class="panel-empty-state-title">Loading Sessions</p> <p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
<p class="panel-empty-state-description">Fetching your previous sessions...</p> <p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
</div> </div>
</Show> </Show>
} }
@@ -357,9 +359,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel-header"> <div class="panel-header">
<div class="flex flex-row flex-wrap items-center gap-2 justify-between"> <div class="flex flex-row flex-wrap items-center gap-2 justify-between">
<div> <div>
<h2 class="panel-title">Resume Session</h2> <h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
<p class="panel-subtitle"> <p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available {parentSessions().length === 1
? t("instanceWelcome.resume.subtitle.one", { count: parentSessions().length })
: t("instanceWelcome.resume.subtitle.other", { count: parentSessions().length })}
</p> </p>
</div> </div>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}> <Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
@@ -368,7 +372,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
class="button-tertiary lg:hidden flex-shrink-0" class="button-tertiary lg:hidden flex-shrink-0"
onClick={openInstanceInfoOverlay} onClick={openInstanceInfoOverlay}
> >
View Instance Info {t("instanceWelcome.actions.viewInstanceInfo")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -404,7 +408,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
"text-accent": isFocused(), "text-accent": isFocused(),
}} }}
> >
{session.title || "Untitled Session"} {session.title || t("instanceWelcome.session.untitled")}
</span> </span>
</div> </div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5"> <div class="flex items-center gap-3 text-xs text-muted mt-0.5">
@@ -421,7 +425,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<button <button
type="button" type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Rename session" title={t("instanceWelcome.actions.renameTitle")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -433,7 +437,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<button <button
type="button" type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Delete session" title={t("instanceWelcome.actions.deleteTitle")}
disabled={isSessionDeleting(session.id)} disabled={isSessionDeleting(session.id)}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
@@ -470,8 +474,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel flex-shrink-0"> <div class="panel flex-shrink-0">
<div class="panel-header"> <div class="panel-header">
<h2 class="panel-title">Start New Session</h2> <h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
<p class="panel-subtitle">Well reuse your last agent/model automatically</p> <p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="space-y-3"> <div class="space-y-3">
@@ -496,7 +500,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
)} )}
<span>Create Session</span> <span>{t("instanceWelcome.new.createButton")}</span>
</div> </div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2" /> <Kbd shortcut={newSessionShortcutString()} class="ml-2" />
</button> </button>
@@ -524,7 +528,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
> >
<div class="flex justify-end"> <div class="flex justify-end">
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}> <button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
Close {t("instanceWelcome.overlay.close")}
</button> </button>
</div> </div>
<div class="max-h-[85vh] overflow-y-auto pr-1"> <div class="max-h-[85vh] overflow-y-auto pr-1">
@@ -541,25 +545,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>
<span>Navigate</span> <span>{t("instanceWelcome.hints.navigate")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">PgUp</kbd> <kbd class="kbd">PgUp</kbd>
<kbd class="kbd">PgDn</kbd> <kbd class="kbd">PgDn</kbd>
<span>Jump</span> <span>{t("instanceWelcome.hints.jump")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">Home</kbd> <kbd class="kbd">Home</kbd>
<kbd class="kbd">End</kbd> <kbd class="kbd">End</kbd>
<span>First/Last</span> <span>{t("instanceWelcome.hints.firstLast")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd> <kbd class="kbd">Enter</kbd>
<span>Resume</span> <span>{t("instanceWelcome.hints.resume")}</span>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd> <kbd class="kbd">Del</kbd>
<span>Delete</span> <span>{t("instanceWelcome.hints.delete")}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -67,6 +67,7 @@ import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
import { import {
SESSION_SIDEBAR_EVENT, SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction, type SessionSidebarRequestAction,
@@ -121,6 +122,8 @@ function persistPinState(side: "left" | "right", value: boolean) {
} }
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n()
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH) const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
const [leftPinned, setLeftPinned] = createSignal(true) const [leftPinned, setLeftPinned] = createSignal(true)
@@ -357,6 +360,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return "disconnected" return "disconnected"
} }
const connectionStatusLabel = () => {
const status = connectionStatus()
if (status === "connected") return t("instanceShell.connection.connected")
if (status === "connecting") return t("instanceShell.connection.connecting")
if (status === "error" || status === "disconnected") return t("instanceShell.connection.disconnected")
return t("instanceShell.connection.unknown")
}
const handleCommandPaletteClick = () => { const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id) showCommandPalette(props.instance.id)
} }
@@ -716,16 +727,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const leftAppBarButtonLabel = () => { const leftAppBarButtonLabel = () => {
const state = leftDrawerState() const state = leftDrawerState()
if (state === "pinned") return "Left drawer pinned" if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
if (state === "floating-closed") return "Open left drawer" if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open")
return "Close left drawer" return t("instanceShell.leftDrawer.toggle.close")
} }
const rightAppBarButtonLabel = () => { const rightAppBarButtonLabel = () => {
const state = rightDrawerState() const state = rightDrawerState()
if (state === "pinned") return "Right drawer pinned" if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
if (state === "floating-closed") return "Open right drawer" if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open")
return "Close right drawer" return t("instanceShell.rightDrawer.toggle.close")
} }
const leftAppBarButtonIcon = () => { const leftAppBarButtonIcon = () => {
@@ -855,7 +866,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}> <div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base"> <div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="session-sidebar-shortcuts"> <div class="session-sidebar-shortcuts">
<Show when={keyboardShortcuts().length}> <Show when={keyboardShortcuts().length}>
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} /> <KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
@@ -866,8 +879,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label="Instance Info" aria-label={t("instanceShell.leftPanel.instanceInfo")}
title="Instance Info" title={t("instanceShell.leftPanel.instanceInfo")}
onClick={() => handleSessionSelect("info")} onClick={() => handleSessionSelect("info")}
> >
<InfoOutlinedIcon fontSize="small" /> <InfoOutlinedIcon fontSize="small" />
@@ -876,7 +889,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"} aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
> >
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} {leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
@@ -935,19 +948,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const renderPlanSectionContent = () => { const renderPlanSectionContent = () => {
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") { if (!sessionId || sessionId === "info") {
return <p class="text-xs text-secondary">Select a session to view plan.</p> return <p class="text-xs text-secondary">{t("instanceShell.plan.noSessionSelected")}</p>
} }
const todoState = latestTodoState() const todoState = latestTodoState()
if (!todoState) { if (!todoState) {
return <p class="text-xs text-secondary">Nothing planned yet.</p> return <p class="text-xs text-secondary">{t("instanceShell.plan.empty")}</p>
} }
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} /> return <TodoListView state={todoState} emptyLabel={t("instanceShell.plan.empty")} showStatusLabel={false} />
} }
const renderBackgroundProcesses = () => { const renderBackgroundProcesses = () => {
const processes = backgroundProcessList() const processes = backgroundProcessList()
if (processes.length === 0) { if (processes.length === 0) {
return <p class="text-xs text-secondary">No background processes.</p> return <p class="text-xs text-secondary">{t("instanceShell.backgroundProcesses.empty")}</p>
} }
return ( return (
@@ -958,9 +971,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-primary">{process.title}</span> <span class="text-xs font-semibold text-primary">{process.title}</span>
<div class="flex flex-wrap gap-2 text-[11px] text-secondary"> <div class="flex flex-wrap gap-2 text-[11px] text-secondary">
<span>Status: {process.status}</span> <span>{t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}> <Show when={typeof process.outputSizeBytes === "number"}>
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span> <span>
{t("instanceShell.backgroundProcesses.output", {
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
})}
</span>
</Show> </Show>
</div> </div>
</div> </div>
@@ -969,8 +986,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button" type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center" class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => openBackgroundOutput(process)} onClick={() => openBackgroundOutput(process)}
aria-label="Output" aria-label={t("instanceShell.backgroundProcesses.actions.output")}
title="Output" title={t("instanceShell.backgroundProcesses.actions.output")}
> >
<TerminalSquare class="h-4 w-4" /> <TerminalSquare class="h-4 w-4" />
</button> </button>
@@ -979,8 +996,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
class="button-tertiary w-full p-1 inline-flex items-center justify-center" class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"} disabled={process.status !== "running"}
onClick={() => stopBackgroundProcess(process.id)} onClick={() => stopBackgroundProcess(process.id)}
aria-label="Stop" aria-label={t("instanceShell.backgroundProcesses.actions.stop")}
title="Stop" title={t("instanceShell.backgroundProcesses.actions.stop")}
> >
<XOctagon class="h-4 w-4" /> <XOctagon class="h-4 w-4" />
</button> </button>
@@ -988,8 +1005,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button" type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center" class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => terminateBackgroundProcess(process.id)} onClick={() => terminateBackgroundProcess(process.id)}
aria-label="Terminate" aria-label={t("instanceShell.backgroundProcesses.actions.terminate")}
title="Terminate" title={t("instanceShell.backgroundProcesses.actions.terminate")}
> >
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</button> </button>
@@ -1004,17 +1021,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const sections = [ const sections = [
{ {
id: "plan", id: "plan",
label: "Plan", labelKey: "instanceShell.rightPanel.sections.plan",
render: renderPlanSectionContent, render: renderPlanSectionContent,
}, },
{ {
id: "background-processes", id: "background-processes",
label: "Background Shells", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
render: renderBackgroundProcesses, render: renderBackgroundProcesses,
}, },
{ {
id: "mcp", id: "mcp",
label: "MCP Servers", labelKey: "instanceShell.rightPanel.sections.mcp",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -1026,7 +1043,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}, },
{ {
id: "lsp", id: "lsp",
label: "LSP Servers", labelKey: "instanceShell.rightPanel.sections.lsp",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -1038,7 +1055,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}, },
{ {
id: "plugins", id: "plugins",
label: "Plugins", labelKey: "instanceShell.rightPanel.sections.plugins",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -1066,14 +1083,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}> <div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex items-center justify-between px-4 py-2 border-b border-base"> <div class="flex items-center justify-between px-4 py-2 border-b border-base">
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold"> <Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
Status Panel {t("instanceShell.rightPanel.title")}
</Typography> </Typography>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={!isPhoneLayout()}> <Show when={!isPhoneLayout()}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"} aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
> >
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} {rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
@@ -1097,7 +1114,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<Accordion.Header> <Accordion.Header>
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide"> <Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
<span>{section.label}</span> <span>{t(section.labelKey)}</span>
<ChevronDown <ChevronDown
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`} class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
/> />
@@ -1274,17 +1291,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label="Open command palette" aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
> >
Command Palette {t("instanceShell.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
<span <span
class={`status-indicator ${connectionStatusClass()}`} class={`status-indicator ${connectionStatusClass()}`}
aria-label={`Connection ${connectionStatus()}`} aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
> >
<span class="status-dot" /> <span class="status-dot" />
</span> </span>
@@ -1307,11 +1324,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-2 pb-1"> <div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span> <span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span> <span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div> </div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span> <span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span> <span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div> </div>
</div> </div>
@@ -1333,11 +1354,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Show when={!showingInfoView()}> <Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span> <span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span> <span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div> </div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span> <span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span> <span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div> </div>
</Show> </Show>
@@ -1353,10 +1378,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label="Open command palette" aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
> >
Command Palette {t("instanceShell.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
@@ -1371,19 +1396,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Show when={connectionStatus() === "connected"}> <Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected"> <span class="status-indicator connected">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Connected</span> <span class="status-text">{t("instanceShell.connection.connected")}</span>
</span> </span>
</Show> </Show>
<Show when={connectionStatus() === "connecting"}> <Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting"> <span class="status-indicator connecting">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Connecting...</span> <span class="status-text">{t("instanceShell.connection.connecting")}</span>
</span> </span>
</Show> </Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}> <Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected"> <span class="status-indicator disconnected">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Disconnected</span> <span class="status-text">{t("instanceShell.connection.disconnected")}</span>
</span> </span>
</Show> </Show>
</div> </div>
@@ -1419,8 +1444,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
fallback={ fallback={
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400"> <div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p> <p class="mb-2">{t("instanceShell.empty.title")}</p>
<p class="text-sm">Select a session to view messages</p> <p class="text-sm">{t("instanceShell.empty.description")}</p>
</div> </div>
</div> </div>
} }

View File

@@ -1,6 +1,7 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface LogsViewProps { interface LogsViewProps {
instanceId: string instanceId: string
@@ -9,6 +10,7 @@ interface LogsViewProps {
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>() const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const LogsView: Component<LogsViewProps> = (props) => { const LogsView: Component<LogsViewProps> = (props) => {
const { t } = useI18n()
let scrollRef: HTMLDivElement | undefined let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId) const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
@@ -83,18 +85,18 @@ const LogsView: Component<LogsViewProps> = (props) => {
return ( return (
<div class="log-container"> <div class="log-container">
<div class="log-header"> <div class="log-header">
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3> <h3 class="text-sm font-medium" style="color: var(--text-secondary)">{t("logsView.title")}</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show <Show
when={streamingEnabled()} when={streamingEnabled()}
fallback={ fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}> <button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs {t("logsView.actions.show")}
</button> </button>
} }
> >
<button type="button" class="button-tertiary" onClick={handleDisableLogs}> <button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs {t("logsView.actions.hide")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -103,7 +105,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}> <Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
<div class="env-vars-container"> <div class="env-vars-container">
<div class="env-vars-title"> <div class="env-vars-title">
Environment Variables ({Object.keys(instance()?.environmentVariables!).length}) {t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })}
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<For each={Object.entries(instance()?.environmentVariables!)}> <For each={Object.entries(instance()?.environmentVariables!)}>
@@ -130,17 +132,17 @@ const LogsView: Component<LogsViewProps> = (props) => {
when={streamingEnabled()} when={streamingEnabled()}
fallback={ fallback={
<div class="log-paused-state"> <div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p> <p class="log-paused-title">{t("logsView.paused.title")}</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p> <p class="log-paused-description">{t("logsView.paused.description")}</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}> <button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs {t("logsView.actions.show")}
</button> </button>
</div> </div>
} }
> >
<Show <Show
when={logs().length > 0} when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>} fallback={<div class="log-empty-state">{t("logsView.empty.waiting")}</div>}
> >
<For each={logs()}> <For each={logs()}>
{(entry) => ( {(entry) => (
@@ -160,7 +162,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
class="scroll-to-bottom" class="scroll-to-bottom"
> >
<ChevronDown class="w-4 h-4" /> <ChevronDown class="w-4 h-4" />
Scroll to bottom {t("logsView.scrollToBottom")}
</button> </button>
</Show> </Show>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -34,6 +35,7 @@ interface MarkdownProps {
} }
export function Markdown(props: MarkdownProps) { export function Markdown(props: MarkdownProps) {
const { t } = useI18n()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let latestRequestedText = "" let latestRequestedText = ""
@@ -145,14 +147,14 @@ export function Markdown(props: MarkdownProps) {
const copyText = copyButton.querySelector(".copy-text") const copyText = copyButton.querySelector(".copy-text")
if (copyText) { if (copyText) {
if (success) { if (success) {
copyText.textContent = "Copied!" copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => { setTimeout(() => {
copyText.textContent = "Copy" copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000) }, 2000)
} else { } else {
copyText.textContent = "Failed" copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => { setTimeout(() => {
copyText.textContent = "Copy" copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000) }, 2000)
} }
} }

View File

@@ -11,6 +11,7 @@ import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters" import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { useI18n } from "../lib/i18n"
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -236,6 +237,7 @@ interface MessageBlockProps {
} }
export default function MessageBlock(props: MessageBlockProps) { export default function MessageBlock(props: MessageBlockProps) {
const { t } = useI18n()
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
@@ -465,8 +467,8 @@ export default function MessageBlock(props: MessageBlockProps) {
<div class="tool-call-header-label"> <div class="tool-call-header-label">
<div class="tool-call-header-meta"> <div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span> <span class="tool-call-icon">{TOOL_ICON}</span>
<span>Tool Call</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span> <span class="tool-name">{toolItem.toolPart.tool || t("messageBlock.tool.unknown")}</span>
</div> </div>
<Show when={taskSessionId}> <Show when={taskSessionId}>
<button <button
@@ -474,9 +476,9 @@ export default function MessageBlock(props: MessageBlockProps) {
type="button" type="button"
disabled={!taskLocation} disabled={!taskLocation}
onClick={handleGoToTaskSession} onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"} title={!taskLocation ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
> >
Go to Session {t("messageBlock.tool.goToSession.label")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -538,8 +540,9 @@ interface StepCardProps {
} }
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) { function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
const { t } = useI18n()
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you") const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
const containerClass = () => const containerClass = () =>
@@ -550,7 +553,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
class={containerClass()} class={containerClass()}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label="Session compaction" aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<div class="message-compaction-row"> <div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
@@ -561,6 +564,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
} }
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const { t } = useI18n()
const timestamp = () => { const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value) const date = new Date(value)
@@ -607,12 +611,12 @@ function StepCard(props: StepCardProps) {
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => { const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
{ label: "Input", value: usage.input, formatter: formatTokenTotal }, { label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
{ label: "Output", value: usage.output, formatter: formatTokenTotal }, { label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal }, { label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal }, { label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal }, { label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
{ label: "Cost", value: usage.cost, formatter: formatCostValue }, { label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
] ]
return ( return (
@@ -647,8 +651,8 @@ function StepCard(props: StepCardProps) {
<div class="message-step-title-left"> <div class="message-step-title-left">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show> <Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show> <Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
</span> </span>
</Show> </Show>
</div> </div>
@@ -675,6 +679,7 @@ interface ReasoningCardProps {
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
createEffect(() => { createEffect(() => {
@@ -746,19 +751,29 @@ function ReasoningCard(props: ReasoningCardProps) {
class="message-reasoning-toggle" class="message-reasoning-toggle"
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label flex flex-wrap items-center gap-2"> <span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>Thinking</span> <span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show> <Show when={agentIdentifier()}>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show> {(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span> </span>
</Show> </Show>
</span> </span>
<span class="message-reasoning-meta"> <span class="message-reasoning-meta">
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span> <span class="message-reasoning-indicator">
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
</span>
<span class="message-reasoning-time">{timestamp()}</span> <span class="message-reasoning-time">{timestamp()}</span>
</span> </span>
</button> </button>
@@ -766,7 +781,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label="Reasoning details"> <div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text">{reasoningText() || ""}</pre> <pre class="message-reasoning-text">{reasoningText() || ""}</pre>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -19,6 +20,7 @@ interface MessageItemProps {
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
@@ -49,15 +51,15 @@ export default function MessageItem(props: MessageItemProps) {
} }
const url = part.url || "" const url = part.url || ""
if (url.startsWith("data:")) { if (url.startsWith("data:")) {
return "attachment" return t("messageItem.attachment.defaultName")
} }
try { try {
const parsed = new URL(url) const parsed = new URL(url)
const segments = parsed.pathname.split("/") const segments = parsed.pathname.split("/")
return segments.pop() || "attachment" return segments.pop() || t("messageItem.attachment.defaultName")
} catch (error) { } catch (error) {
const fallback = url.split("/").pop() const fallback = url.split("/").pop()
return fallback && fallback.length > 0 ? fallback : "attachment" return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName")
} }
} }
@@ -112,16 +114,16 @@ export default function MessageItem(props: MessageItemProps) {
const error = info.error const error = info.error
if (error.name === "ProviderAuthError") { if (error.name === "ProviderAuthError") {
return error.data?.message || "Authentication error" return error.data?.message || t("messageItem.errors.authenticationFallback")
} }
if (error.name === "MessageOutputLengthError") { if (error.name === "MessageOutputLengthError") {
return "Message output length exceeded" return t("messageItem.errors.outputLengthExceeded")
} }
if (error.name === "MessageAbortedError") { if (error.name === "MessageAbortedError") {
return "Request was aborted" return t("messageItem.errors.requestAborted")
} }
if (error.name === "UnknownError") { if (error.name === "UnknownError") {
return error.data?.message || "Unknown error occurred" return error.data?.message || t("messageItem.errors.unknownFallback")
} }
return null return null
} }
@@ -170,7 +172,7 @@ export default function MessageItem(props: MessageItemProps) {
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]" ? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]" : "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? "You" : "Assistant") const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant"))
const agentIdentifier = () => { const agentIdentifier = () => {
if (isUser()) return "" if (isUser()) return ""
@@ -195,10 +197,10 @@ export default function MessageItem(props: MessageItemProps) {
const agent = agentIdentifier() const agent = agentIdentifier()
const model = modelIdentifier() const model = modelIdentifier()
if (agent) { if (agent) {
segments.push(`Agent: ${agent}`) segments.push(t("messageItem.agentMeta.agentLabel", { agent }))
} }
if (model) { if (model) {
segments.push(`Model: ${model}`) segments.push(t("messageItem.agentMeta.modelLabel", { model }))
} }
return segments.join(" • ") return segments.join(" • ")
} }
@@ -220,30 +222,30 @@ export default function MessageItem(props: MessageItemProps) {
<button <button
class="message-action-button" class="message-action-button"
onClick={handleRevert} onClick={handleRevert}
title="Revert to this message" title={t("messageItem.actions.revertTitle")}
aria-label="Revert to this message" aria-label={t("messageItem.actions.revertTitle")}
> >
Revert {t("messageItem.actions.revert")}
</button> </button>
</Show> </Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
onClick={() => props.onFork?.(props.record.id)} onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message" title={t("messageItem.actions.forkTitle")}
aria-label="Fork from this message" aria-label={t("messageItem.actions.forkTitle")}
> >
Fork {t("messageItem.actions.fork")}
</button> </button>
</Show> </Show>
<button <button
class="message-action-button" class="message-action-button"
onClick={handleCopy} onClick={handleCopy}
title="Copy message" title={t("messageItem.actions.copyTitle")}
aria-label="Copy message" aria-label={t("messageItem.actions.copyTitle")}
> >
<Show when={copied()} fallback="Copy"> <Show when={copied()} fallback={t("messageItem.actions.copy")}>
Copied! {t("messageItem.actions.copied")}
</Show> </Show>
</button> </button>
</div> </div>
@@ -252,11 +254,11 @@ export default function MessageItem(props: MessageItemProps) {
<button <button
class="message-action-button" class="message-action-button"
onClick={handleCopy} onClick={handleCopy}
title="Copy message" title={t("messageItem.actions.copyTitle")}
aria-label="Copy message" aria-label={t("messageItem.actions.copyTitle")}
> >
<Show when={copied()} fallback="Copy"> <Show when={copied()} fallback={t("messageItem.actions.copy")}>
Copied! {t("messageItem.actions.copied")}
</Show> </Show>
</button> </button>
</Show> </Show>
@@ -269,7 +271,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={props.isQueued && isUser()}> <Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div> <div class="message-queued-badge">{t("messageItem.status.queued")}</div>
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
@@ -278,7 +280,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={isGenerating()}> <Show when={isGenerating()}>
<div class="message-generating"> <div class="message-generating">
<span class="generating-spinner"></span> Generating... <span class="generating-spinner"></span> {t("messageItem.status.generating")}
</div> </div>
</Show> </Show>
@@ -319,7 +321,7 @@ export default function MessageItem(props: MessageItemProps) {
type="button" type="button"
onClick={() => void handleAttachmentDownload(attachment)} onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download" class="attachment-download"
aria-label={`Download ${name}`} aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
> >
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
@@ -340,12 +342,12 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> Sending... <span class="generating-spinner"></span> {t("messageItem.status.sending")}
</div> </div>
</Show> </Show>
<Show when={props.record.status === "error"}> <Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div> <div class="message-error"> {t("messageItem.status.failedToSend")}</div>
</Show> </Show>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import Kbd from "./kbd" import Kbd from "./kbd"
import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary" const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70" const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
@@ -17,6 +18,7 @@ interface MessageListHeaderProps {
} }
export default function MessageListHeader(props: MessageListHeaderProps) { export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number" const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--") const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
@@ -29,7 +31,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
type="button" type="button"
class="session-sidebar-menu-button" class="session-sidebar-menu-button"
onClick={() => props.onSidebarToggle?.()} onClick={() => props.onSidebarToggle?.()}
aria-label="Open session list" aria-label={t("messageListHeader.sidebar.openSessionListAriaLabel")}
> >
<span aria-hidden="true" class="session-sidebar-menu-icon"></span> <span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button> </button>
@@ -39,11 +41,11 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info"> <div class="connection-status-text connection-status-info">
<div class="connection-status-usage"> <div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}> <div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Used</span> <span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span> <span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div> </div>
<div class={METRIC_CHIP_CLASS}> <div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Avail</span> <span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span> <span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div> </div>
</div> </div>
@@ -51,8 +53,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-shortcut"> <div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action"> <div class="connection-status-shortcut-action">
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette"> <button
Command Palette type="button"
class="connection-status-button"
onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
>
{t("messageListHeader.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
@@ -64,19 +71,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<Show when={props.connectionStatus === "connected"}> <Show when={props.connectionStatus === "connected"}>
<span class="status-indicator connected"> <span class="status-indicator connected">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Connected</span> <span class="status-text">{t("messageListHeader.connection.connected")}</span>
</span> </span>
</Show> </Show>
<Show when={props.connectionStatus === "connecting"}> <Show when={props.connectionStatus === "connecting"}>
<span class="status-indicator connecting"> <span class="status-indicator connecting">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Connecting...</span> <span class="status-text">{t("messageListHeader.connection.connecting")}</span>
</span> </span>
</Show> </Show>
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}> <Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
<span class="status-indicator disconnected"> <span class="status-indicator disconnected">
<span class="status-dot" /> <span class="status-dot" />
<span class="status-text">Disconnected</span> <span class="status-text">{t("messageListHeader.connection.disconnected")}</span>
</span> </span>
</Show> </Show>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions" import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useI18n } from "../lib/i18n"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
@@ -31,6 +32,7 @@ export interface MessageSectionProps {
export default function MessageSection(props: MessageSectionProps) { export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig() const { preferences } = useConfig()
const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
@@ -107,7 +109,7 @@ export default function MessageSection(props: MessageSectionProps) {
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record) return if (!record) return
seenTimelineMessageIds.add(messageId) seenTimelineMessageIds.add(messageId)
const built = buildTimelineSegments(props.instanceId, record) const built = buildTimelineSegments(props.instanceId, record, t)
built.forEach((segment) => { built.forEach((segment) => {
const key = makeTimelineKey(segment) const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return if (seenTimelineSegmentKeys.has(key)) return
@@ -121,7 +123,7 @@ export default function MessageSection(props: MessageSectionProps) {
function appendTimelineForMessage(messageId: string) { function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId)) const record = untrack(() => store().getMessage(messageId))
if (!record) return if (!record) return
const built = buildTimelineSegments(props.instanceId, record) const built = buildTimelineSegments(props.instanceId, record, t)
if (built.length === 0) return if (built.length === 0) return
const newSegments: TimelineSegment[] = [] const newSegments: TimelineSegment[] = []
built.forEach((segment) => { built.forEach((segment) => {
@@ -558,7 +560,7 @@ export default function MessageSection(props: MessageSectionProps) {
} }
previousLastTimelineMessageId = lastId previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record) const built = buildTimelineSegments(props.instanceId, record, t)
const newSegments: TimelineSegment[] = [] const newSegments: TimelineSegment[] = []
built.forEach((segment) => { built.forEach((segment) => {
const key = makeTimelineKey(segment) const key = makeTimelineKey(segment)
@@ -753,19 +755,19 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6"> <div class="flex flex-col items-center gap-3 mb-6">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" /> <img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
</div> </div>
<h3>Start a conversation</h3> <h3>{t("messageSection.empty.title")}</h3>
<p>Type a message below or open the Command Palette:</p> <p>{t("messageSection.empty.description")}</p>
<ul> <ul>
<li> <li>
<span>Command Palette</span> <span>{t("messageSection.empty.tips.commandPalette")}</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" /> <Kbd shortcut="cmd+shift+p" class="ml-2" />
</li> </li>
<li>Ask about your codebase</li> <li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li> <li>
Attach files with <code>@</code> {t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
</li> </li>
</ul> </ul>
</div> </div>
@@ -775,7 +777,7 @@ export default function MessageSection(props: MessageSectionProps) {
<Show when={props.loading}> <Show when={props.loading}>
<div class="loading-state"> <div class="loading-state">
<div class="spinner" /> <div class="spinner" />
<p>Loading messages...</p> <p>{t("messageSection.loading.messages")}</p>
</div> </div>
</Show> </Show>
@@ -803,7 +805,7 @@ export default function MessageSection(props: MessageSectionProps) {
<Show when={showScrollTopButton() || showScrollBottomButton()}> <Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper"> <div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}> <Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message"> <button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={t("messageSection.scroll.toFirstAriaLabel")}>
<span class="message-scroll-icon" aria-hidden="true"></span> <span class="message-scroll-icon" aria-hidden="true"></span>
</button> </button>
</Show> </Show>
@@ -812,7 +814,7 @@ export default function MessageSection(props: MessageSectionProps) {
type="button" type="button"
class="message-scroll-button" class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })} onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message" aria-label={t("messageSection.scroll.toLatestAriaLabel")}
> >
<span class="message-scroll-icon" aria-hidden="true"></span> <span class="message-scroll-icon" aria-hidden="true"></span>
</button> </button>
@@ -828,10 +830,10 @@ export default function MessageSection(props: MessageSectionProps) {
> >
<div class="message-quote-button-group"> <div class="message-quote-button-group">
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}> <button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
Add as quote {t("messageSection.quote.addAsQuote")}
</button> </button>
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}> <button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
Add as code {t("messageSection.quote.addAsCode")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -29,14 +30,6 @@ interface MessageTimelineProps {
showToolSegments?: boolean showToolSegments?: boolean
} }
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
user: "You",
assistant: "Asst",
tool: "Tool",
compaction: "Compaction",
}
const TOOL_FALLBACK_LABEL = "Tool Call"
const MAX_TOOLTIP_LENGTH = 220 const MAX_TOOLTIP_LENGTH = 220
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -90,7 +83,7 @@ function collectReasoningText(part: ClientPart): string {
return "" return ""
} }
function collectTextFromPart(part: ClientPart): string { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return "" if (!part) return ""
if (typeof (part as any).text === "string") { if (typeof (part as any).text === "string") {
return (part as any).text as string return (part as any).text as string
@@ -106,26 +99,28 @@ function collectTextFromPart(part: ClientPart): string {
} }
if (part.type === "file") { if (part.type === "file") {
const filename = (part as any)?.filename const filename = (part as any)?.filename
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment" return typeof filename === "string" && filename.length > 0
? t("messageTimeline.text.filePrefix", { filename })
: t("messageTimeline.text.attachment")
} }
return "" return ""
} }
function getToolTitle(part: ToolCallPart): string { function getToolTitle(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown } const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
if (title) return title if (title) return title
if (typeof part.tool === "string" && part.tool.length > 0) { if (typeof part.tool === "string" && part.tool.length > 0) {
return part.tool return part.tool
} }
return TOOL_FALLBACK_LABEL return t("messageTimeline.tool.fallbackLabel")
} }
function getToolTypeLabel(part: ToolCallPart): string { function getToolTypeLabel(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (typeof part.tool === "string" && part.tool.trim().length > 0) { if (typeof part.tool === "string" && part.tool.trim().length > 0) {
return part.tool.trim().slice(0, 4) return part.tool.trim().slice(0, 4)
} }
return TOOL_FALLBACK_LABEL.slice(0, 4) return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
} }
function formatTextsTooltip(texts: string[], fallback: string): string { function formatTextsTooltip(texts: string[], fallback: string): string {
@@ -139,20 +134,34 @@ function formatTextsTooltip(texts: string[], fallback: string): string {
return fallback return fallback
} }
function formatToolTooltip(titles: string[]): string { function formatToolTooltip(
titles: string[],
t: (key: string, params?: Record<string, unknown>) => string,
): string {
if (titles.length === 0) { if (titles.length === 0) {
return TOOL_FALLBACK_LABEL return t("messageTimeline.tool.fallbackLabel")
} }
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`) return truncateText(`${t("messageTimeline.tool.fallbackLabel")}: ${titles.join(", ")}`)
} }
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] { export function buildTimelineSegments(
instanceId: string,
record: MessageRecord,
t: (key: string, params?: Record<string, unknown>) => string,
): TimelineSegment[] {
if (!record) return [] if (!record) return []
const { orderedParts } = buildRecordDisplayData(instanceId, record) const { orderedParts } = buildRecordDisplayData(instanceId, record)
if (!orderedParts || orderedParts.length === 0) { if (!orderedParts || orderedParts.length === 0) {
return [] return []
} }
const segmentLabel = (type: TimelineSegmentType) => {
if (type === "user") return t("messageTimeline.segment.user.label")
if (type === "assistant") return t("messageTimeline.segment.assistant.label")
if (type === "compaction") return t("messageTimeline.segment.compaction.label")
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
}
const result: TimelineSegment[] = [] const result: TimelineSegment[] = []
let segmentIndex = 0 let segmentIndex = 0
let pending: PendingSegment | null = null let pending: PendingSegment | null = null
@@ -164,14 +173,14 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
} }
const isToolSegment = pending.type === "tool" const isToolSegment = pending.type === "tool"
const label = isToolSegment const label = isToolSegment
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4) ? pending.toolTypeLabels[0] || segmentLabel("tool")
: SEGMENT_LABELS[pending.type] : segmentLabel(pending.type)
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
const tooltip = isToolSegment const tooltip = isToolSegment
? formatToolTooltip(pending.toolTitles) ? formatToolTooltip(pending.toolTitles, t)
: formatTextsTooltip( : formatTextsTooltip(
[...pending.texts, ...pending.reasoningTexts], [...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? "User message" : "Assistant response", pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
) )
result.push({ result.push({
@@ -204,8 +213,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
if (part.type === "tool") { if (part.type === "tool") {
const target = ensureSegment("tool") const target = ensureSegment("tool")
const toolPart = part as ToolCallPart const toolPart = part as ToolCallPart
target.toolTitles.push(getToolTitle(toolPart)) target.toolTitles.push(getToolTitle(toolPart, t))
target.toolTypeLabels.push(getToolTypeLabel(toolPart)) target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool")) target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) { if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id) target.toolPartIds.push(toolPart.id)
@@ -230,8 +239,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
type: "compaction", type: "compaction",
label: SEGMENT_LABELS.compaction, label: segmentLabel("compaction"),
tooltip: isAuto ? "Auto Compaction" : "User Compaction", tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
}) })
segmentIndex += 1 segmentIndex += 1
@@ -242,7 +251,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
continue continue
} }
const text = collectTextFromPart(part) const text = collectTextFromPart(part, t)
if (text.trim().length === 0) continue if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
@@ -258,6 +267,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
} }
const MessageTimeline: Component<MessageTimelineProps> = (props) => { const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const { t } = useI18n()
const buttonRefs = new Map<string, HTMLButtonElement>() const buttonRefs = new Map<string, HTMLButtonElement>()
const store = () => messageStoreBus.getOrCreate(props.instanceId) const store = () => messageStoreBus.getOrCreate(props.instanceId)
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null) const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
@@ -360,7 +370,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
return ( return (
<div class="message-timeline" role="navigation" aria-label="Message timeline"> <div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
<For each={props.segments}> <For each={props.segments}>
{(segment) => { {(segment) => {
onCleanup(() => buttonRefs.delete(segment.id)) onCleanup(() => buttonRefs.delete(segment.id))
@@ -438,4 +448,3 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
export default MessageTimeline export default MessageTimeline

View File

@@ -3,6 +3,7 @@ import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions" import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session" import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import Kbd from "./kbd" import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -22,6 +23,7 @@ interface FlatModel extends Model {
} }
export default function ModelSelector(props: ModelSelectorProps) { export default function ModelSelector(props: ModelSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || [] const instanceProviders = () => providers().get(props.instanceId) || []
const [isOpen, setIsOpen] = createSignal(false) const [isOpen, setIsOpen] = createSignal(false)
let triggerRef!: HTMLButtonElement let triggerRef!: HTMLButtonElement
@@ -75,7 +77,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
optionValue="key" optionValue="key"
optionTextValue="searchText" optionTextValue="searchText"
optionLabel="name" optionLabel="name"
placeholder="Search models..." placeholder={t("modelSelector.placeholder.search")}
defaultFilter={customFilter} defaultFilter={customFilter}
allowsEmptyCollection allowsEmptyCollection
itemComponent={(itemProps) => ( itemComponent={(itemProps) => (
@@ -108,7 +110,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
> >
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0"> <div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
Model: {currentModelValue()?.name ?? "None"} {t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span> </span>
{currentModelValue() && ( {currentModelValue() && (
<span class="selector-trigger-secondary"> <span class="selector-trigger-secondary">
@@ -131,7 +133,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
<Combobox.Input <Combobox.Input
ref={searchInputRef} ref={searchInputRef}
class="selector-search-input" class="selector-search-input"
placeholder="Search models..." placeholder={t("modelSelector.placeholder.search")}
/> />
</div> </div>
<Combobox.Listbox class="selector-listbox" /> <Combobox.Listbox class="selector-listbox" />

View File

@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog" import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions" import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -23,6 +24,7 @@ interface OpenCodeBinarySelectorProps {
} }
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => { const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
const { t } = useI18n()
const { const {
opencodeBinaries, opencodeBinaries,
addOpenCodeBinary, addOpenCodeBinary,
@@ -103,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
if (validatingPaths().has(path)) { if (validatingPaths().has(path)) {
return { valid: false, error: "Already validating" } return { valid: false, error: t("opencodeBinarySelector.validation.alreadyValidating") }
} }
try { try {
@@ -139,7 +141,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(null) setValidationError(null)
if (nativeDialogsAvailable) { if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({ const selected = await openNativeFileDialog({
title: "Select OpenCode Binary", title: t("opencodeBinarySelector.dialog.title"),
}) })
if (selected) { if (selected) {
setCustomPath(selected) setCustomPath(selected)
@@ -160,7 +162,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setCustomPath("") setCustomPath("")
setValidationError(null) setValidationError(null)
} else { } else {
setValidationError(validation.error || "Invalid OpenCode binary") setValidationError(validation.error || t("opencodeBinarySelector.validation.invalidBinary"))
} }
} }
@@ -202,14 +204,14 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago` if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return `${hours}h ago` if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return `${minutes}m ago` if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return "just now" return t("time.relative.justNow")
} }
function getDisplayName(path: string): string { function getDisplayName(path: string): string {
if (path === "opencode") return "opencode (system PATH)" if (path === "opencode") return t("opencodeBinarySelector.display.systemPath", { name: "opencode" })
const parts = path.split(/[/\\]/) const parts = path.split(/[/\\]/)
return parts[parts.length - 1] ?? path return parts[parts.length - 1] ?? path
} }
@@ -221,13 +223,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<div class="panel"> <div class="panel">
<div class="panel-header flex items-center justify-between gap-3"> <div class="panel-header flex items-center justify-between gap-3">
<div> <div>
<h3 class="panel-title">OpenCode Binary</h3> <h3 class="panel-title">{t("opencodeBinarySelector.title")}</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p> <p class="panel-subtitle">{t("opencodeBinarySelector.subtitle")}</p>
</div> </div>
<Show when={validating()}> <Show when={validating()}>
<div class="selector-loading text-xs"> <div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" /> <Loader2 class="selector-loading-spinner" />
<span>Checking versions</span> <span>{t("opencodeBinarySelector.status.checkingVersions")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -245,7 +247,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
}} }}
disabled={props.disabled} disabled={props.disabled}
placeholder="Enter path to opencode binary…" placeholder={t("opencodeBinarySelector.customPath.placeholder")}
class="selector-input" class="selector-input"
/> />
<button <button
@@ -255,7 +257,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="selector-button selector-button-primary" class="selector-button selector-button-primary"
> >
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
Add {t("opencodeBinarySelector.actions.add")}
</button> </button>
</div> </div>
@@ -266,7 +268,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2" class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
> >
<FolderOpen class="w-4 h-4" /> <FolderOpen class="w-4 h-4" />
Browse for Binary {t("opencodeBinarySelector.actions.browse")}
</button> </button>
<Show when={validationError()}> <Show when={validationError()}>
@@ -308,16 +310,16 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
</Show> </Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap"> <div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}> <Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span> <span class="selector-badge-version">{t("opencodeBinarySelector.versionLabel", { version: versionLabel() })}</span>
</Show> </Show>
<Show when={isPathValidating(binary.path)}> <Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span> <span class="selector-badge-time">{t("opencodeBinarySelector.status.checking")}</span>
</Show> </Show>
<Show when={!isDefault && binary.lastUsed}> <Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span> <span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show> </Show>
<Show when={isDefault}> <Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span> <span class="selector-badge-time">{t("opencodeBinarySelector.badge.systemPath")}</span>
</Show> </Show>
</div> </div>
</div> </div>
@@ -328,7 +330,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="p-2 text-muted hover:text-primary" class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)} onClick={(event) => handleRemoveBinary(binary.path, event)}
disabled={props.disabled} disabled={props.disabled}
title="Remove binary" title={t("opencodeBinarySelector.actions.removeTitle")}
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</button> </button>
@@ -343,8 +345,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<FileSystemBrowserDialog <FileSystemBrowserDialog
open={isBinaryBrowserOpen()} open={isBinaryBrowserOpen()}
mode="files" mode="files"
title="Select OpenCode Binary" title={t("opencodeBinarySelector.dialog.title")}
description="Browse files exposed by the CLI server." description={t("opencodeBinarySelector.dialog.description")}
onClose={() => setIsBinaryBrowserOpen(false)} onClose={() => setIsBinaryBrowserOpen(false)}
onSelect={handleBinaryBrowserSelect} onSelect={handleBinaryBrowserSelect}
/> />
@@ -353,4 +355,3 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
export default OpenCodeBinarySelector export default OpenCodeBinarySelector

View File

@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Comp
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import { useI18n } from "../lib/i18n"
import { import {
activeInterruption, activeInterruption,
getPermissionQueue, getPermissionQueue,
@@ -130,6 +131,7 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
} }
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => { const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const { t } = useI18n()
const [loadingSession, setLoadingSession] = createSignal<string | null>(null) const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set()) const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map()) const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
@@ -165,7 +167,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const sessionId = getPermissionSessionId(permission) || "" const sessionId = getPermissionSessionId(permission) || ""
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response) await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
} catch (error) { } catch (error) {
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission") setPermissionItemError(
permissionId,
error instanceof Error ? error.message : t("permissionApproval.errors.unableToUpdatePermission"),
)
} finally { } finally {
setPermissionBusy(permissionId, false) setPermissionBusy(permissionId, false)
} }
@@ -257,19 +262,24 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<div class="permission-center-modal-header"> <div class="permission-center-modal-header">
<div class="permission-center-modal-title-row"> <div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title"> <h2 id="permission-center-title" class="permission-center-modal-title">
Requests {t("permissionApproval.title")}
</h2> </h2>
<Show when={orderedQueue().length > 0}> <Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{orderedQueue().length}</span> <span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show> </Show>
</div> </div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close"> <button
type="button"
class="permission-center-modal-close"
onClick={props.onClose}
aria-label={t("permissionApproval.actions.closeAriaLabel")}
>
</button> </button>
</div> </div>
<div class="permission-center-modal-body"> <div class="permission-center-modal-body">
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}> <Show when={hasRequests()} fallback={<div class="permission-center-empty">{t("permissionApproval.empty")}</div>}>
<div class="permission-center-list" role="list"> <div class="permission-center-list" role="list">
<For each={orderedQueue()}> <For each={orderedQueue()}>
{(item) => { {(item) => {
@@ -285,14 +295,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const showFallback = () => !resolved() const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question") const kindLabel = () =>
item.kind === "permission"
? t("permissionApproval.kind.permission")
: t("permissionApproval.kind.question")
const primaryTitle = () => { const primaryTitle = () => {
if (item.kind === "permission") { if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload) return getPermissionDisplayTitle(item.payload)
} }
const first = item.payload.questions?.[0]?.question const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question" return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question")
} }
const secondaryTitle = () => { const secondaryTitle = () => {
@@ -300,7 +313,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
return getPermissionKind(item.payload) return getPermissionKind(item.payload)
} }
const count = item.payload.questions?.length ?? 0 const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions` return count === 1
? t("permissionApproval.questionCount.one", { count })
: t("permissionApproval.questionCount.other", { count })
} }
return ( return (
@@ -313,7 +328,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span> <span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span> <span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}> <Show when={isActive()}>
<span class="permission-center-item-chip">Active</span> <span class="permission-center-item-chip">{t("permissionApproval.status.active")}</span>
</Show> </Show>
</div> </div>
@@ -326,7 +341,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
handleGoToSession(sessionId()) handleGoToSession(sessionId())
}} }}
> >
Go to Session {t("permissionApproval.actions.goToSession")}
</button> </button>
<Show when={showFallback()}> <Show when={showFallback()}>
<button <button
@@ -338,7 +353,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
handleLoadSession(sessionId()) handleLoadSession(sessionId())
}} }}
> >
{loadingSession() === sessionId() ? "Loading…" : "Load Session"} {loadingSession() === sessionId()
? t("permissionApproval.actions.loadingSession")
: t("permissionApproval.actions.loadSession")}
</button> </button>
</Show> </Show>
</div> </div>
@@ -360,7 +377,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)} disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")} onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
> >
Allow Once {t("permissionApproval.actions.allowOnce")}
</button> </button>
<button <button
type="button" type="button"
@@ -368,7 +385,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)} disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")} onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
> >
Always Allow {t("permissionApproval.actions.alwaysAllow")}
</button> </button>
<button <button
type="button" type="button"
@@ -376,7 +393,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)} disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")} onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
> >
Deny {t("permissionApproval.actions.deny")}
</button> </button>
</div> </div>
</div> </div>
@@ -385,7 +402,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
</Show> </Show>
</Show> </Show>
<Show when={item.kind !== "permission"}> <Show when={item.kind !== "permission"}>
<div class="permission-center-fallback-hint">Load session for more information.</div> <div class="permission-center-fallback-hint">{t("permissionApproval.fallbackHint")}</div>
</Show> </Show>
</div> </div>
} }

View File

@@ -1,5 +1,6 @@
import { Show, createMemo, type Component } from "solid-js" import { Show, createMemo, type Component } from "solid-js"
import { ShieldAlert } from "lucide-solid" import { ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances" import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
interface PermissionNotificationBannerProps { interface PermissionNotificationBannerProps {
@@ -8,17 +9,38 @@ interface PermissionNotificationBannerProps {
} }
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => { const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
const { t } = useI18n()
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId)) const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId)) const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
const queueLength = createMemo(() => permissionCount() + questionCount()) const queueLength = createMemo(() => permissionCount() + questionCount())
const hasRequests = createMemo(() => queueLength() > 0) const hasRequests = createMemo(() => queueLength() > 0)
const label = createMemo(() => { const label = createMemo(() => {
const total = queueLength() const total = queueLength()
const pendingLabel = total === 1
? t("permissionBanner.pendingRequests.one", { count: total })
: t("permissionBanner.pendingRequests.other", { count: total })
const parts: string[] = [] const parts: string[] = []
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`) if (permissionCount() > 0) {
const detail = parts.length ? ` (${parts.join(", ")})` : "" parts.push(
return `${total} pending request${total === 1 ? "" : "s"}${detail}` permissionCount() === 1
? t("permissionBanner.detail.permission.one", { count: permissionCount() })
: t("permissionBanner.detail.permission.other", { count: permissionCount() }),
)
}
if (questionCount() > 0) {
parts.push(
questionCount() === 1
? t("permissionBanner.detail.question.one", { count: questionCount() })
: t("permissionBanner.detail.question.other", { count: questionCount() }),
)
}
const detail = parts.length ? t("permissionBanner.detail.wrapper", { detail: parts.join(", ") }) : ""
return `${pendingLabel}${detail}`
}) })
return ( return (

View File

@@ -14,6 +14,7 @@ import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands" import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -32,6 +33,7 @@ interface PromptInputProps {
} }
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n()
const [prompt, setPromptInternal] = createSignal("") const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([]) const [history, setHistory] = createSignal<string[]>([])
const HISTORY_LIMIT = 100 const HISTORY_LIMIT = 100
@@ -53,9 +55,9 @@ export default function PromptInput(props: PromptInputProps) {
const getPlaceholder = () => { const getPlaceholder = () => {
if (mode() === "shell") { if (mode() === "shell") {
return "Run a shell command (Esc to exit)..." return t("promptInput.placeholder.shell")
} }
return "Type your message, @file, @agent, or paste images and text..." return t("promptInput.placeholder.default")
} }
@@ -642,8 +644,8 @@ export default function PromptInput(props: PromptInputProps) {
} }
} catch (error) { } catch (error) {
log.error("Failed to send message:", error) log.error("Failed to send message:", error)
showAlertDialog("Failed to send message", { showAlertDialog(t("promptInput.send.errorFallback"), {
title: "Send failed", title: t("promptInput.send.errorTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
@@ -1048,8 +1050,11 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0 return hasText || attachments().length > 0
} }
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" }) const shellHint = () =>
const commandHint = () => ({ key: "/", text: "Commands" }) mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
: { key: "!", text: t("promptInput.hints.shell.enable") }
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
@@ -1115,7 +1120,7 @@ export default function PromptInput(props: PromptInputProps) {
class="prompt-history-button" class="prompt-history-button"
onClick={() => selectPreviousHistory(true)} onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()} disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt" aria-label={t("promptInput.history.previousAriaLabel")}
> >
<ArrowBigUp class="h-5 w-5" aria-hidden="true" /> <ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button> </button>
@@ -1124,7 +1129,7 @@ export default function PromptInput(props: PromptInputProps) {
class="prompt-history-button" class="prompt-history-button"
onClick={() => selectNextHistory(true)} onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()} disabled={!canHistoryGoNext()}
aria-label="Next prompt" aria-label={t("promptInput.history.nextAriaLabel")}
> >
<ArrowBigDown class="h-5 w-5" aria-hidden="true" /> <ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button> </button>
@@ -1137,10 +1142,10 @@ export default function PromptInput(props: PromptInputProps) {
fallback={ fallback={
<> <>
<span class="prompt-overlay-text"> <span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History <Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")} <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} <Kbd></Kbd> {t("promptInput.overlay.history")}
</span> </span>
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span> <span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
</Show> </Show>
<span class="prompt-overlay-text"> <span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text} <Kbd>{shellHint().key}</Kbd> {shellHint().text}
@@ -1151,17 +1156,17 @@ export default function PromptInput(props: PromptInputProps) {
</span> </span>
</Show> </Show>
<Show when={mode() === "shell"}> <Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span> <span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show> </Show>
</> </>
} }
> >
<> <>
<span class="prompt-overlay-text prompt-overlay-warning"> <span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session {t("promptInput.overlay.press")} <Kbd>Esc</Kbd> {t("promptInput.overlay.againToAbort")}
</span> </span>
<Show when={mode() === "shell"}> <Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span> <span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show> </Show>
</> </>
</Show> </Show>
@@ -1177,8 +1182,8 @@ export default function PromptInput(props: PromptInputProps) {
class="stop-button" class="stop-button"
onClick={handleAbort} onClick={handleAbort}
disabled={!canStop()} disabled={!canStop()}
aria-label="Stop session" aria-label={t("promptInput.stopSession.ariaLabel")}
title="Stop session" title={t("promptInput.stopSession.title")}
> >
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="4" y="4" width="12" height="12" rx="2" /> <rect x="4" y="4" width="12" height="12" rx="2" />
@@ -1189,7 +1194,7 @@ export default function PromptInput(props: PromptInputProps) {
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`} class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend} onClick={handleSend}
disabled={!canSend()} disabled={!canSend()}
aria-label="Send message" aria-label={t("promptInput.send.ariaLabel")}
> >
<Show <Show
when={mode() === "shell"} when={mode() === "shell"}

View File

@@ -9,6 +9,7 @@ import { restartCli } from "../lib/native/cli"
import { preferences, setListeningMode } from "../stores/preferences" import { preferences, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts" import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("actions") const log = getLogger("actions")
@@ -18,6 +19,7 @@ interface RemoteAccessOverlayProps {
} }
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null) const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
@@ -85,11 +87,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return return
} }
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", { const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? "Open to other devices" : "Limit to this device", title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning", variant: "warning",
confirmLabel: "Restart now", confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: "Cancel", cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
}) })
if (!confirmed) { if (!confirmed) {
@@ -100,7 +102,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
setListeningMode(targetMode) setListeningMode(targetMode)
const restarted = await restartCli() const restarted = await restartCli()
if (!restarted) { if (!restarted) {
setError("Unable to restart automatically. Please restart the app to apply the change.") setError(t("remoteAccess.restart.errorManual"))
} else { } else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
} }
@@ -123,12 +125,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const confirm = passwordConfirm() const confirm = passwordConfirm()
if (next.trim().length < 8) { if (next.trim().length < 8) {
setPasswordError("Password must be at least 8 characters.") setPasswordError(t("remoteAccess.password.error.tooShort"))
return return
} }
if (next !== confirm) { if (next !== confirm) {
setPasswordError("Passwords do not match.") setPasswordError(t("remoteAccess.password.error.mismatch"))
return return
} }
@@ -162,11 +164,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}> <Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
<header class="remote-header"> <header class="remote-header">
<div> <div>
<p class="remote-eyebrow">Remote handover</p> <p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
<h2 class="remote-title">Connect to CodeNomad remotely</h2> <h2 class="remote-title">{t("remoteAccess.title")}</h2>
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p> <p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
</div> </div>
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access"> <button type="button" class="remote-close" onClick={props.onClose} aria-label={t("remoteAccess.close")}>
× ×
</button> </button>
</header> </header>
@@ -177,13 +179,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title"> <div class="remote-section-title">
<Shield class="remote-icon" /> <Shield class="remote-icon" />
<div> <div>
<p class="remote-label">Listening mode</p> <p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p> <p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
</div> </div>
</div> </div>
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}> <button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} /> <RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
<span class="remote-refresh-label">Refresh</span> <span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
</button> </button>
</div> </div>
@@ -196,19 +198,18 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
> >
<Switch.Input /> <Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}> <Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span> <span class="remote-toggle-state">{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}</span>
<Switch.Thumb class="remote-toggle-thumb" /> <Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control> </Switch.Control>
<div class="remote-toggle-copy"> <div class="remote-toggle-copy">
<span class="remote-toggle-title">Allow connections from other IPs</span> <span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
<span class="remote-toggle-caption"> <span class="remote-toggle-caption">
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"} {allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")}
</span> </span>
</div> </div>
</Switch> </Switch>
<p class="remote-toggle-note"> <p class="remote-toggle-note">
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the {t("remoteAccess.toggle.note")}
server restarts.
</p> </p>
</section> </section>
@@ -217,22 +218,24 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title"> <div class="remote-section-title">
<Shield class="remote-icon" /> <Shield class="remote-icon" />
<div> <div>
<p class="remote-label">Server password</p> <p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p> <p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
</div> </div>
</div> </div>
</div> </div>
<Show <Show
when={authStatus() && authStatus()!.authenticated} when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">Authentication status unavailable.</div>} fallback={<div class="remote-card">{t("remoteAccess.authStatus.unavailable")}</div>}
> >
<div class="remote-card"> <div class="remote-card">
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p> <p class="remote-help">
{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}
</p>
<p class="remote-help"> <p class="remote-help">
{authStatus()!.passwordUserProvided {authStatus()!.passwordUserProvided
? "A password is set for remote access." ? t("remoteAccess.password.status.set")
: "No memorable password is set yet. Set one to allow remote handover logins."} : t("remoteAccess.password.status.unset")}
</p> </p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}> <div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
@@ -245,26 +248,26 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
}} }}
> >
{passwordFormOpen() {passwordFormOpen()
? "Cancel" ? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided : authStatus()!.passwordUserProvided
? "Change password" ? t("remoteAccess.password.actions.change")
: "Set password"} : t("remoteAccess.password.actions.set")}
</button> </button>
</div> </div>
<Show when={passwordFormOpen()}> <Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}> <div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">New password</label> <label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.newPassword")}</label>
<input <input
class="selector-input w-full" class="selector-input w-full"
type="password" type="password"
value={passwordValue()} value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)} onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder="At least 8 characters" placeholder={t("remoteAccess.password.form.placeholder")}
/> />
</div> </div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}> <div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">Confirm password</label> <label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.confirmPassword")}</label>
<input <input
class="selector-input w-full" class="selector-input w-full"
type="password" type="password"
@@ -284,7 +287,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
disabled={savingPassword()} disabled={savingPassword()}
onClick={() => void handleSubmitPassword()} onClick={() => void handleSubmitPassword()}
> >
{savingPassword() ? "Saving" : "Save password"} {savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
</button> </button>
</div> </div>
</Show> </Show>
@@ -298,33 +301,39 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title"> <div class="remote-section-title">
<Wifi class="remote-icon" /> <Wifi class="remote-icon" />
<div> <div>
<p class="remote-label">Reachable addresses</p> <p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
<p class="remote-help">Launch or scan from another machine to hand over control.</p> <p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
</div> </div>
</div> </div>
</div> </div>
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses</div>}> <Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}> <Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}> <Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list"> <div class="remote-address-list">
<For each={displayAddresses()}> <For each={displayAddresses()}>
{(address) => { {(address) => {
const expandedState = () => expandedUrl() === address.url const expandedState = () => expandedUrl() === address.url
const qr = () => qrCodes()[address.url] const qr = () => qrCodes()[address.url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return ( return (
<div class="remote-address"> <div class="remote-address">
<div class="remote-address-main"> <div class="remote-address-main">
<div> <div>
<p class="remote-address-url">{address.url}</p> <p class="remote-address-url">{address.url}</p>
<p class="remote-address-meta"> <p class="remote-address-meta">
{address.family.toUpperCase()} {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} {address.ip} {address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p> </p>
</div> </div>
<div class="remote-actions"> <div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}> <button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
<ExternalLink class="remote-icon" /> <ExternalLink class="remote-icon" />
Open {t("remoteAccess.address.open")}
</button> </button>
<button <button
class="remote-pill" class="remote-pill"
@@ -333,14 +342,20 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
aria-expanded={expandedState()} aria-expanded={expandedState()}
> >
<Link2 class="remote-icon" /> <Link2 class="remote-icon" />
{expandedState() ? "Hide QR" : "Show QR"} {expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button> </button>
</div> </div>
</div> </div>
<Show when={expandedState()}> <Show when={expandedState()}>
<div class="remote-qr"> <div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}> <Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />} {(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
class="remote-qr-img"
/>
)}
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@@ -7,6 +7,7 @@ import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n"
import { import {
deleteSession, deleteSession,
ensureSessionParentExpanded, ensureSessionParentExpanded,
@@ -37,17 +38,11 @@ interface SessionListProps {
} }
function formatSessionStatus(status: SessionStatus): string { function formatSessionStatus(status: SessionStatus): string {
switch (status) { return status
case "working":
return "Working"
case "compacting":
return "Compacting"
default:
return "Idle"
}
} }
const SessionList: Component<SessionListProps> = (props) => { const SessionList: Component<SessionListProps> = (props) => {
const { t } = useI18n()
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false) const [isRenaming, setIsRenaming] = createSignal(false)
@@ -73,13 +68,13 @@ const SessionList: Component<SessionListProps> = (props) => {
try { try {
const success = await copyToClipboard(sessionId) const success = await copyToClipboard(sessionId)
if (success) { if (success) {
showToastNotification({ message: "Session ID copied", variant: "success" }) showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" })
} else { } else {
showToastNotification({ message: "Unable to copy session ID", variant: "error" }) showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
} }
} catch (error) { } catch (error) {
log.error(`Failed to copy session ID ${sessionId}:`, error) log.error(`Failed to copy session ID ${sessionId}:`, error)
showToastNotification({ message: "Unable to copy session ID", variant: "error" }) showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
} }
} }
@@ -127,7 +122,7 @@ const SessionList: Component<SessionListProps> = (props) => {
} }
} catch (error) { } catch (error) {
log.error(`Failed to delete session ${sessionId}:`, error) log.error(`Failed to delete session ${sessionId}:`, error)
showToastNotification({ message: "Unable to delete session", variant: "error" }) showToastNotification({ message: t("sessionList.delete.error"), variant: "error" })
} }
} }
@@ -152,7 +147,7 @@ const SessionList: Component<SessionListProps> = (props) => {
setRenameTarget(null) setRenameTarget(null)
} catch (error) { } catch (error) {
log.error(`Failed to rename session ${target.id}:`, error) log.error(`Failed to rename session ${target.id}:`, error)
showToastNotification({ message: "Unable to rename session", variant: "error" }) showToastNotification({ message: t("sessionList.rename.error"), variant: "error" })
} finally { } finally {
setIsRenaming(false) setIsRenaming(false)
} }
@@ -172,14 +167,28 @@ const SessionList: Component<SessionListProps> = (props) => {
return <></> return <></>
} }
const isActive = () => props.activeSessionId === rowProps.sessionId const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || "Untitled" const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status()) const statusLabel = () => {
switch (formatSessionStatus(status())) {
case "working":
return t("sessionList.status.working")
case "compacting":
return t("sessionList.status.compacting")
default:
return t("sessionList.status.idle")
}
}
const needsPermission = () => Boolean(session()?.pendingPermission) const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion() const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`) const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel()) const statusText = () =>
needsPermission()
? t("sessionList.status.needsPermission")
: needsQuestion()
? t("sessionList.status.needsInput")
: statusLabel()
return ( return (
<div class="session-list-item group"> <div class="session-list-item group">
@@ -219,8 +228,8 @@ const SessionList: Component<SessionListProps> = (props) => {
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"} aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
title={rowProps.expanded ? "Collapse" : "Expand"} title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
> >
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
@@ -240,8 +249,8 @@ const SessionList: Component<SessionListProps> = (props) => {
onClick={(event) => copySessionId(event, rowProps.sessionId)} onClick={(event) => copySessionId(event, rowProps.sessionId)}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Copy session ID" aria-label={t("sessionList.actions.copyId.ariaLabel")}
title="Copy session ID" title={t("sessionList.actions.copyId.title")}
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</span> </span>
@@ -253,8 +262,8 @@ const SessionList: Component<SessionListProps> = (props) => {
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Rename session" aria-label={t("sessionList.actions.rename.ariaLabel")}
title="Rename session" title={t("sessionList.actions.rename.title")}
> >
<Pencil class="w-3 h-3" /> <Pencil class="w-3 h-3" />
</span> </span>
@@ -263,8 +272,8 @@ const SessionList: Component<SessionListProps> = (props) => {
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)} onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Delete session" aria-label={t("sessionList.actions.delete.ariaLabel")}
title="Delete session" title={t("sessionList.actions.delete.title")}
> >
<Show <Show
when={!isSessionDeleting(rowProps.sessionId)} when={!isSessionDeleting(rowProps.sessionId)}
@@ -360,7 +369,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-header p-3 border-b border-base"> <div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? ( {props.headerContent ?? (
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">Sessions</h3> <h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
<KeyboardHint <KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)} shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/> />
@@ -420,4 +429,3 @@ const SessionList: Component<SessionListProps> = (props) => {
} }
export default SessionList export default SessionList

View File

@@ -5,6 +5,7 @@ import { getParentSessions, createSession, setActiveParentSession } from "../sto
import { instances, stopInstance } from "../stores/instances" import { instances, stopInstance } from "../stores/instances"
import { agents } from "../stores/sessions" import { agents } from "../stores/sessions"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -15,6 +16,7 @@ interface SessionPickerProps {
} }
const SessionPicker: Component<SessionPickerProps> = (props) => { const SessionPicker: Component<SessionPickerProps> = (props) => {
const { t } = useI18n()
const [selectedAgent, setSelectedAgent] = createSignal<string>("") const [selectedAgent, setSelectedAgent] = createSignal<string>("")
const [isCreating, setIsCreating] = createSignal(false) const [isCreating, setIsCreating] = createSignal(false)
@@ -40,10 +42,10 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago` if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return `${hours}h ago` if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return `${minutes}m ago` if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return "just now" return t("time.relative.justNow")
} }
async function handleSessionSelect(sessionId: string) { async function handleSessionSelect(sessionId: string) {
@@ -74,19 +76,19 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6"> <Dialog.Content class="modal-surface w-full max-w-lg p-6">
<Dialog.Title class="text-xl font-semibold text-primary mb-4"> <Dialog.Title class="text-xl font-semibold text-primary mb-4">
OpenCode {instance()?.folder.split("/").pop()} {t("sessionPicker.title", { folder: instance()?.folder.split("/").pop() })}
</Dialog.Title> </Dialog.Title>
<div class="space-y-6"> <div class="space-y-6">
<Show <Show
when={parentSessions().length > 0} when={parentSessions().length > 0}
fallback={<div class="text-center py-4 text-sm text-muted">No previous sessions</div>} fallback={<div class="text-center py-4 text-sm text-muted">{t("sessionPicker.empty.noPrevious")}</div>}
> >
<div> <div>
<h3 class="text-sm font-medium text-secondary mb-2"> <h3 class="text-sm font-medium text-secondary mb-2">
Resume a session ({parentSessions().length}): {t("sessionPicker.resume.title", { count: parentSessions().length })}
</h3> </h3>
<div class="space-y-1 max-h-[400px] overflow-y-auto"> <div class="space-y-1 max-h-[400px] overflow-y-auto">
<For each={parentSessions()}> <For each={parentSessions()}>
@@ -98,7 +100,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
> >
<div class="selector-option-content w-full"> <div class="selector-option-content w-full">
<span class="selector-option-label truncate"> <span class="selector-option-label truncate">
{session.title || "Untitled"} {session.title || t("sessionPicker.session.untitled")}
</span> </span>
</div> </div>
<span class="selector-badge-time flex-shrink-0"> <span class="selector-badge-time flex-shrink-0">
@@ -116,16 +118,16 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
<div class="w-full border-t border-base" /> <div class="w-full border-t border-base" />
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-surface-base text-muted">or</span> <span class="px-2 bg-surface-base text-muted">{t("sessionPicker.divider.or")}</span>
</div> </div>
</div> </div>
<div> <div>
<h3 class="text-sm font-medium text-secondary mb-2">Start new session:</h3> <h3 class="text-sm font-medium text-secondary mb-2">{t("sessionPicker.new.title")}</h3>
<div class="space-y-3"> <div class="space-y-3">
<Show <Show
when={agentList().length > 0} when={agentList().length > 0}
fallback={<div class="text-sm text-muted">Loading agents...</div>} fallback={<div class="text-sm text-muted">{t("sessionPicker.agents.loading")}</div>}
> >
<select <select
class="selector-input w-full" class="selector-input w-full"
@@ -161,9 +163,13 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</Show> </Show>
<Show <Show
when={!isCreating()} when={!isCreating()}
fallback={<span>Creating...</span>} fallback={<span>{t("sessionPicker.actions.creating")}</span>}
> >
<span>{agentList().length === 0 ? "Loading agents..." : "Create Session"}</span> <span>
{agentList().length === 0
? t("sessionPicker.agents.loading")
: t("sessionPicker.actions.createSession")}
</span>
</Show> </Show>
</div> </div>
<kbd class="kbd ml-2"> <kbd class="kbd ml-2">
@@ -180,7 +186,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
class="selector-button selector-button-secondary" class="selector-button selector-button-secondary"
onClick={handleCancel} onClick={handleCancel}
> >
Cancel {t("sessionPicker.actions.cancel")}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>

View File

@@ -1,5 +1,6 @@
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js" import { Component, Show, createEffect, createSignal } from "solid-js"
import { useI18n } from "../lib/i18n"
interface SessionRenameDialogProps { interface SessionRenameDialogProps {
open: boolean open: boolean
@@ -11,6 +12,7 @@ interface SessionRenameDialogProps {
} }
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => { const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const { t } = useI18n()
const [title, setTitle] = createSignal("") const [title, setTitle] = createSignal("")
const inputId = `session-rename-${Math.random().toString(36).slice(2)}` const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
let inputRef: HTMLInputElement | undefined let inputRef: HTMLInputElement | undefined
@@ -40,9 +42,9 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const description = () => { const description = () => {
if (props.sessionLabel && props.sessionLabel.trim()) { if (props.sessionLabel && props.sessionLabel.trim()) {
return `Update the title for "${props.sessionLabel}".` return t("sessionRenameDialog.description.withLabel", { label: props.sessionLabel })
} }
return "Set a new title for this session." return t("sessionRenameDialog.description.default")
} }
return ( return (
@@ -58,7 +60,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}> <Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title> <Dialog.Title class="text-lg font-semibold text-primary">{t("sessionRenameDialog.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1"> <Dialog.Description class="text-sm text-secondary mt-1">
{description()} {description()}
</Dialog.Description> </Dialog.Description>
@@ -66,7 +68,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
<form class="mt-4 space-y-4" onSubmit={handleRename}> <form class="mt-4 space-y-4" onSubmit={handleRename}>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-secondary" for={inputId}> <label class="text-sm font-medium text-secondary" for={inputId}>
Session name {t("sessionRenameDialog.input.label")}
</label> </label>
<input <input
id={inputId} id={inputId}
@@ -76,7 +78,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
type="text" type="text"
value={title()} value={title()}
onInput={(event) => setTitle(event.currentTarget.value)} onInput={(event) => setTitle(event.currentTarget.value)}
placeholder="Enter a session name" placeholder={t("sessionRenameDialog.input.placeholder")}
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent" class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
/> />
</div> </div>
@@ -92,7 +94,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
}} }}
disabled={isSubmitting()} disabled={isSubmitting()}
> >
Cancel {t("sessionRenameDialog.actions.cancel")}
</button> </button>
<button <button
type="submit" type="submit"
@@ -111,11 +113,11 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
<span>Renaming</span> <span>{t("sessionRenameDialog.actions.renaming")}</span>
</> </>
} }
> >
Rename {t("sessionRenameDialog.actions.rename")}
</Show> </Show>
</button> </button>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { createMemo, type Component } from "solid-js" import { createMemo, type Component } from "solid-js"
import { getSessionInfo } from "../../stores/sessions" import { getSessionInfo } from "../../stores/sessions"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
import { useI18n } from "../../lib/i18n"
interface ContextUsagePanelProps { interface ContextUsagePanelProps {
instanceId: string instanceId: string
@@ -12,6 +13,7 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide" const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => { const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const { t } = useI18n()
const info = createMemo( const info = createMemo(
() => () =>
getSessionInfo(props.instanceId, props.sessionId) ?? { getSessionInfo(props.instanceId, props.sessionId) ?? {
@@ -39,7 +41,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const formatTokenValue = (value: number | null | undefined) => { const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return "--" if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
return formatTokenTotal(value) return formatTokenTotal(value)
} }
@@ -48,29 +50,29 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
return ( return (
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3"> <div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90"> <div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Tokens</div> <div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>Input</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span> <span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
</div> </div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>Output</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.output")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span> <span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
</div> </div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>Cost</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
<span class="font-semibold text-primary">{costDisplay()}</span> <span class="font-semibold text-primary">{costDisplay()}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90"> <div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Context</div> <div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>Used</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span> <span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div> </div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>Avail</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span> <span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-stat
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -34,6 +35,7 @@ interface SessionViewProps {
} }
export const SessionView: Component<SessionViewProps> = (props) => { export const SessionView: Component<SessionViewProps> = (props) => {
const { t } = useI18n()
const session = () => props.activeSessions.get(props.sessionId) const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
@@ -152,8 +154,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id }) log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
} catch (error) { } catch (error) {
log.error("Failed to abort session", error) log.error("Failed to abort session", error)
showAlertDialog("Failed to stop session", { showAlertDialog(t("sessionView.alerts.abortFailed.message"), {
title: "Stop failed", title: t("sessionView.alerts.abortFailed.title"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
@@ -201,8 +203,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} catch (error) { } catch (error) {
log.error("Failed to revert message", error) log.error("Failed to revert message", error)
showAlertDialog("Failed to revert to message", { showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
title: "Revert failed", title: t("sessionView.alerts.revertFailed.title"),
variant: "error", variant: "error",
}) })
} }
@@ -237,8 +239,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} catch (error) { } catch (error) {
log.error("Failed to fork session", error) log.error("Failed to fork session", error)
showAlertDialog("Failed to fork session", { showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
title: "Fork failed", title: t("sessionView.alerts.forkFailed.title"),
variant: "error", variant: "error",
}) })
} }
@@ -250,7 +252,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
when={session()} when={session()}
fallback={ fallback={
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div> <div class="text-center text-gray-500">{t("sessionView.fallback.sessionNotFound")}</div>
</div> </div>
} }
> >
@@ -296,8 +298,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
type="button" type="button"
class="attachment-expand" class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)} onClick={() => handleExpandTextAttachment(attachment)}
aria-label="Expand pasted text" aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title="Insert pasted text" title={t("sessionView.attachments.insertPastedTextTitle")}
> >
<Expand class="h-3 w-3" aria-hidden="true" /> <Expand class="h-3 w-3" aria-hidden="true" />
</button> </button>
@@ -306,7 +308,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
type="button" type="button"
class="attachment-remove" class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)} onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label="Remove attachment" aria-label={t("sessionView.attachments.removeAriaLabel")}
> >
× ×
</button> </button>

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences" import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
import Kbd from "./kbd" import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -20,6 +21,7 @@ type ThinkingOption = {
} }
export default function ThinkingSelector(props: ThinkingSelectorProps) { export default function ThinkingSelector(props: ThinkingSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || [] const instanceProviders = () => providers().get(props.instanceId) || []
createEffect(() => { createEffect(() => {
@@ -37,7 +39,10 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
const options = createMemo<ThinkingOption[]>(() => { const options = createMemo<ThinkingOption[]>(() => {
const keys = variantKeys() const keys = variantKeys()
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))] return [
{ key: "__default__", label: t("thinkingSelector.variant.default"), value: undefined },
...keys.map((k) => ({ key: k, label: k, value: k })),
]
}) })
const currentValue = createMemo(() => { const currentValue = createMemo(() => {
@@ -56,7 +61,8 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
const triggerPrimary = createMemo(() => { const triggerPrimary = createMemo(() => {
const selected = currentValue()?.value const selected = currentValue()?.value
return selected ? `Thinking: ${selected}` : "Thinking: Default" const variant = selected ?? t("thinkingSelector.variant.default")
return t("thinkingSelector.label", { variant })
}) })
return ( return (
@@ -67,7 +73,7 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
options={options()} options={options()}
optionValue="key" optionValue="key"
optionLabel="label" optionLabel="label"
placeholder="Thinking: Default" placeholder={t("thinkingSelector.label", { variant: t("thinkingSelector.variant.default") })}
itemComponent={(itemProps) => ( itemComponent={(itemProps) => (
<Combobox.Item item={itemProps.item} class="selector-option"> <Combobox.Item item={itemProps.item} class="selector-option">
<div class="selector-option-content"> <div class="selector-option-content">

View File

@@ -7,6 +7,7 @@ import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQue
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionSessionId } from "../types/permission" import { getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../lib/i18n"
import { resolveToolRenderer } from "./tool-call/renderers" import { resolveToolRenderer } from "./tool-call/renderers"
import { QuestionToolBlock } from "./tool-call/question-block" import { QuestionToolBlock } from "./tool-call/question-block"
import { PermissionToolBlock } from "./tool-call/permission-block" import { PermissionToolBlock } from "./tool-call/permission-block"
@@ -67,6 +68,7 @@ interface ToolCallProps {
export default function ToolCall(props: ToolCallProps) { export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig() const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme() const { isDark } = useTheme()
const { t } = useI18n()
const toolCallMemo = createMemo(() => props.toolCall) const toolCallMemo = createMemo(() => props.toolCall)
const toolName = createMemo(() => toolCallMemo()?.tool || "") const toolName = createMemo(() => toolCallMemo()?.tool || "")
const toolCallIdentifier = createMemo(() => { const toolCallIdentifier = createMemo(() => {
@@ -442,7 +444,7 @@ export default function ToolCall(props: ToolCallProps) {
return row.map((value) => value.trim()).filter((value) => value.length > 0) return row.map((value) => value.trim()).filter((value) => value.length > 0)
}) })
if (normalized.some((item) => (item?.length ?? 0) === 0)) { if (normalized.some((item) => (item?.length ?? 0) === 0)) {
setQuestionError("Please answer all questions before submitting.") setQuestionError(t("toolCall.question.validation.answerAll"))
return return
} }
@@ -453,7 +455,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized) await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
} catch (error) { } catch (error) {
log.error("Failed to send question reply", error) log.error("Failed to send question reply", error)
setQuestionError(error instanceof Error ? error.message : "Unable to reply") setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToReply"))
} finally { } finally {
setQuestionSubmitting(false) setQuestionSubmitting(false)
} }
@@ -471,7 +473,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendQuestionReject(props.instanceId, sessionId, request.id) await sendQuestionReject(props.instanceId, sessionId, request.id)
} catch (error) { } catch (error) {
log.error("Failed to reject question", error) log.error("Failed to reject question", error)
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss") setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToDismiss"))
} finally { } finally {
setQuestionSubmitting(false) setQuestionSubmitting(false)
} }
@@ -545,6 +547,7 @@ export default function ToolCall(props: ToolCallProps) {
preferences, preferences,
setDiffViewMode, setDiffViewMode,
isDark, isDark,
t,
diffCache, diffCache,
permissionDiffCache, permissionDiffCache,
scrollHelpers, scrollHelpers,
@@ -568,6 +571,7 @@ export default function ToolCall(props: ToolCallProps) {
toolCall: toolCallMemo, toolCall: toolCallMemo,
toolState, toolState,
toolName, toolName,
t,
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent, renderMarkdown: renderMarkdownContent,
@@ -639,7 +643,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (error) { } catch (error) {
log.error("Failed to send permission response", error) log.error("Failed to send permission response", error)
setPermissionError(error instanceof Error ? error.message : "Unable to update permission") setPermissionError(error instanceof Error ? error.message : t("toolCall.permission.errors.unableToUpdate"))
} finally { } finally {
setPermissionSubmitting(false) setPermissionSubmitting(false)
} }
@@ -651,7 +655,7 @@ export default function ToolCall(props: ToolCallProps) {
if (state.status === "error" && state.error) { if (state.status === "error" && state.error) {
return ( return (
<div class="tool-call-error-content"> <div class="tool-call-error-content">
<strong>Error:</strong> {state.error} <strong>{t("toolCall.error.label")}</strong> {state.error}
</div> </div>
) )
} }
@@ -752,7 +756,7 @@ export default function ToolCall(props: ToolCallProps) {
<Show when={status() === "pending" && !pendingPermission()}> <Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message"> <div class="tool-call-pending-message">
<span class="spinner-small"></span> <span class="spinner-small"></span>
<span>Waiting to run...</span> <span>{t("toolCall.pending.waitingToRun")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -761,6 +765,7 @@ export default function ToolCall(props: ToolCallProps) {
<Show when={diagnosticsEntries().length}> <Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection( {renderDiagnosticsSection(
t,
diagnosticsEntries(), diagnosticsEntries(),
diagnosticsExpanded(), diagnosticsExpanded(),
() => setDiagnosticsOverride((prev) => { () => setDiagnosticsOverride((prev) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types" import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils" import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en"
import { defaultRenderer } from "./renderers/default" import { defaultRenderer } from "./renderers/default"
import { bashRenderer } from "./renderers/bash" import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read" import { readRenderer } from "./renderers/read"
@@ -43,12 +44,28 @@ function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
} as 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 { function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const toolStateAccessor = () => snapshot.state const toolStateAccessor = () => snapshot.state
const toolNameAccessor = () => snapshot.toolName const toolNameAccessor = () => snapshot.toolName
const toolCallAccessor = () => createStaticToolPart(snapshot) const toolCallAccessor = () => createStaticToolPart(snapshot)
const messageVersionAccessor = () => undefined const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined const partVersionAccessor = () => undefined
const t = createStaticT()
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null const renderDiff: ToolRendererContext["renderDiff"] = () => null
@@ -57,6 +74,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
toolCall: toolCallAccessor, toolCall: toolCallAccessor,
toolState: toolStateAccessor, toolState: toolStateAccessor,
toolName: toolNameAccessor, toolName: toolNameAccessor,
t,
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown, renderMarkdown,

View File

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

View File

@@ -3,6 +3,7 @@ import type { Agent } from "../types/session"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -87,6 +88,7 @@ interface UnifiedPickerProps {
} }
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => { const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const { t } = useI18n()
const mode = () => props.mode ?? "mention" const mode = () => props.mode ?? "mention"
const [files, setFiles] = createSignal<FileItem[]>([]) const [files, setFiles] = createSignal<FileItem[]>([])
@@ -366,10 +368,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const isLoading = () => mode() === "mention" && loadingState() !== "idle" const isLoading = () => mode() === "mention" && loadingState() !== "idle"
const loadingMessage = () => { const loadingMessage = () => {
if (loadingState() === "search") { if (loadingState() === "search") {
return "Searching..." return t("unifiedPicker.loading.searching")
} }
if (loadingState() === "listing") { if (loadingState() === "listing") {
return "Loading workspace..." return t("unifiedPicker.loading.loadingWorkspace")
} }
return "" return ""
} }
@@ -383,8 +385,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
> >
<div class="dropdown-header"> <div class="dropdown-header">
<div class="dropdown-header-title"> <div class="dropdown-header-title">
<Show when={mode() === "command"} fallback={"Select Agent or File"}> <Show when={mode() === "command"} fallback={t("unifiedPicker.title.mention")}>
Select Command {t("unifiedPicker.title.command")}
</Show> </Show>
<Show when={isLoading()}> <Show when={isLoading()}>
<span class="ml-2">{loadingMessage()}</span> <span class="ml-2">{loadingMessage()}</span>
@@ -394,11 +396,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div ref={scrollContainerRef} class="dropdown-content max-h-60"> <div ref={scrollContainerRef} class="dropdown-content max-h-60">
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}> <Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
<div class="dropdown-empty">No results found</div> <div class="dropdown-empty">{t("unifiedPicker.empty")}</div>
</Show> </Show>
<Show when={mode() === "command" && commandCount() > 0}> <Show when={mode() === "command" && commandCount() > 0}>
<div class="dropdown-section-header">COMMANDS</div> <div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
<For each={filteredCommands()}> <For each={filteredCommands()}>
{(command) => { {(command) => {
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name) const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
@@ -429,7 +431,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<Show when={mode() === "mention" && agentCount() > 0}> <Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
AGENTS {t("unifiedPicker.sections.agents")}
</div> </div>
<For each={filteredAgents()}> <For each={filteredAgents()}>
{(agent) => { {(agent) => {
@@ -463,7 +465,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<span class="text-sm font-medium">{agent.name}</span> <span class="text-sm font-medium">{agent.name}</span>
<Show when={agent.mode === "subagent"}> <Show when={agent.mode === "subagent"}>
<span class="dropdown-badge"> <span class="dropdown-badge">
subagent {t("unifiedPicker.badge.subagent")}
</span> </span>
</Show> </Show>
</div> </div>
@@ -484,7 +486,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<Show when={mode() === "mention" && fileCount() > 0}> <Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
FILES {t("unifiedPicker.sections.files")}
</div> </div>
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
@@ -534,8 +536,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div class="dropdown-footer"> <div class="dropdown-footer">
<div> <div>
<span class="font-medium"></span> navigate <span class="font-medium">Tab/Enter</span> select {" "} <span class="font-medium"></span> {t("unifiedPicker.footer.navigate")} <span class="font-medium">Tab/Enter</span> {t("unifiedPicker.footer.select")} {" "}
<span class="font-medium">Esc</span> close <span class="font-medium">Esc</span> {t("unifiedPicker.footer.close")}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
import { Show, createEffect, createSignal } from "solid-js" import { Show, createEffect, createSignal } from "solid-js"
import type { ServerMeta } from "../../../server/src/api-types" import type { ServerMeta } from "../../../server/src/api-types"
import { getServerMeta } from "../lib/server-meta" import { getServerMeta } from "../lib/server-meta"
import { useI18n } from "../lib/i18n"
export default function VersionPill() { export default function VersionPill() {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null) const [meta, setMeta] = createSignal<ServerMeta | null>(null)
createEffect(() => { createEffect(() => {
@@ -15,11 +17,13 @@ export default function VersionPill() {
const uiVersion = () => meta()?.ui?.version const uiVersion = () => meta()?.ui?.version
const uiSource = () => meta()?.ui?.source const uiSource = () => meta()?.ui?.source
const uiLabel = () => (uiVersion() ? t("versionPill.uiWithVersion", { version: uiVersion() }) : t("versionPill.ui"))
return ( return (
<Show when={serverVersion() || uiVersion() || uiSource()}> <Show when={serverVersion() || uiVersion() || uiSource()}>
<div class="text-[11px] text-muted whitespace-nowrap"> <div class="text-[11px] text-muted whitespace-nowrap">
<Show when={serverVersion()}> <Show when={serverVersion()}>
{(v) => <span>App {v()}</span>} {(v) => <span>{t("versionPill.appWithVersion", { version: v() })}</span>}
</Show> </Show>
<Show when={uiVersion() || uiSource()}> <Show when={uiVersion() || uiSource()}>
<> <>
@@ -27,8 +31,8 @@ export default function VersionPill() {
<span class="mx-2">·</span> <span class="mx-2">·</span>
</Show> </Show>
<span> <span>
UI{uiVersion() ? ` ${uiVersion()}` : ""} {uiLabel()}
<Show when={uiSource()}>{(s) => <span class="opacity-70"> ({s()})</span>}</Show> <Show when={uiSource()}>{(s) => <span class="opacity-70">{t("versionPill.source", { source: s() })}</span>}</Show>
</span> </span>
</> </>
</Show> </Show>

View File

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

View File

@@ -6,14 +6,20 @@ export interface KeyboardShortcut {
alt?: boolean 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 { export interface Command {
id: string id: string
label: string | (() => string) label: Resolvable<string>
description: string description: Resolvable<string>
keywords?: string[] keywords?: Resolvable<string[]>
shortcut?: KeyboardShortcut shortcut?: KeyboardShortcut
action: () => void | Promise<void> action: () => void | Promise<void>
category?: string category?: Resolvable<string>
} }
export function createCommandRegistry() { export function createCommandRegistry() {
@@ -47,11 +53,15 @@ export function createCommandRegistry() {
const lowerQuery = query.toLowerCase() const lowerQuery = query.toLowerCase()
return getAll().filter((cmd) => { 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 labelMatch = label.toLowerCase().includes(lowerQuery)
const descMatch = cmd.description.toLowerCase().includes(lowerQuery) const descMatch = description.toLowerCase().includes(lowerQuery)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery)) const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
return labelMatch || descMatch || keywordMatch 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 { getLogger } from "../logger"
import { requestData } from "../opencode-api" import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events" import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
const log = getLogger("actions") const log = getLogger("actions")
function splitKeywords(key: string): string[] {
return tGlobal(key)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
export interface UseCommandsOptions { export interface UseCommandsOptions {
preferences: Accessor<Preferences> preferences: Accessor<Preferences>
@@ -61,20 +69,20 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "new-instance", id: "new-instance",
label: "New Instance", label: () => tGlobal("commands.newInstance.label"),
description: "Open folder picker to create new instance", description: () => tGlobal("commands.newInstance.description"),
category: "Instance", category: "Instance",
keywords: ["folder", "project", "workspace"], keywords: () => splitKeywords("commands.newInstance.keywords"),
shortcut: { key: "N", meta: true }, shortcut: { key: "N", meta: true },
action: options.handleNewInstanceRequest, action: options.handleNewInstanceRequest,
}) })
commandRegistry.register({ commandRegistry.register({
id: "close-instance", id: "close-instance",
label: "Close Instance", label: () => tGlobal("commands.closeInstance.label"),
description: "Stop current instance's server", description: () => tGlobal("commands.closeInstance.description"),
category: "Instance", category: "Instance",
keywords: ["stop", "quit", "close"], keywords: () => splitKeywords("commands.closeInstance.keywords"),
shortcut: { key: "W", meta: true }, shortcut: { key: "W", meta: true },
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
@@ -85,10 +93,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "instance-next", id: "instance-next",
label: "Next Instance", label: () => tGlobal("commands.nextInstance.label"),
description: "Cycle to next instance tab", description: () => tGlobal("commands.nextInstance.description"),
category: "Instance", category: "Instance",
keywords: ["switch", "navigate"], keywords: () => splitKeywords("commands.nextInstance.keywords"),
shortcut: { key: "]", meta: true }, shortcut: { key: "]", meta: true },
action: () => { action: () => {
const ids = Array.from(instances().keys()) const ids = Array.from(instances().keys())
@@ -101,10 +109,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "instance-prev", id: "instance-prev",
label: "Previous Instance", label: () => tGlobal("commands.previousInstance.label"),
description: "Cycle to previous instance tab", description: () => tGlobal("commands.previousInstance.description"),
category: "Instance", category: "Instance",
keywords: ["switch", "navigate"], keywords: () => splitKeywords("commands.previousInstance.keywords"),
shortcut: { key: "[", meta: true }, shortcut: { key: "[", meta: true },
action: () => { action: () => {
const ids = Array.from(instances().keys()) const ids = Array.from(instances().keys())
@@ -117,10 +125,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "new-session", id: "new-session",
label: "New Session", label: () => tGlobal("commands.newSession.label"),
description: "Create a new parent session", description: () => tGlobal("commands.newSession.description"),
category: "Session", category: "Session",
keywords: ["create", "start"], keywords: () => splitKeywords("commands.newSession.keywords"),
shortcut: { key: "N", meta: true, shift: true }, shortcut: { key: "N", meta: true, shift: true },
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
@@ -131,10 +139,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "close-session", id: "close-session",
label: "Close Session", label: () => tGlobal("commands.closeSession.label"),
description: "Close current parent session", description: () => tGlobal("commands.closeSession.description"),
category: "Session", category: "Session",
keywords: ["close", "stop"], keywords: () => splitKeywords("commands.closeSession.keywords"),
shortcut: { key: "W", meta: true, shift: true }, shortcut: { key: "W", meta: true, shift: true },
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
@@ -146,10 +154,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "cleanup-blank-sessions", id: "cleanup-blank-sessions",
label: "Scrub Sessions", label: () => tGlobal("commands.scrubSessions.label"),
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.", description: () => tGlobal("commands.scrubSessions.description"),
category: "Session", category: "Session",
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"], keywords: () => splitKeywords("commands.scrubSessions.keywords"),
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
if (!instance) return if (!instance) return
@@ -159,10 +167,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "switch-to-info", id: "switch-to-info",
label: "Instance Info", label: () => tGlobal("commands.instanceInfo.label"),
description: "Open the instance overview for logs and status", description: () => tGlobal("commands.instanceInfo.description"),
category: "Instance", category: "Instance",
keywords: ["info", "logs", "console", "output"], keywords: () => splitKeywords("commands.instanceInfo.keywords"),
shortcut: { key: "L", meta: true, shift: true }, shortcut: { key: "L", meta: true, shift: true },
action: () => { action: () => {
const instance = activeInstance() const instance = activeInstance()
@@ -172,10 +180,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "session-next", id: "session-next",
label: "Next Session", label: () => tGlobal("commands.nextSession.label"),
description: "Cycle to next session tab", description: () => tGlobal("commands.nextSession.description"),
category: "Session", category: "Session",
keywords: ["switch", "navigate"], keywords: () => splitKeywords("commands.nextSession.keywords"),
shortcut: { key: "]", meta: true, shift: true }, shortcut: { key: "]", meta: true, shift: true },
action: () => { action: () => {
const instanceId = activeInstanceId() const instanceId = activeInstanceId()
@@ -197,10 +205,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "session-prev", id: "session-prev",
label: "Previous Session", label: () => tGlobal("commands.previousSession.label"),
description: "Cycle to previous session tab", description: () => tGlobal("commands.previousSession.description"),
category: "Session", category: "Session",
keywords: ["switch", "navigate"], keywords: () => splitKeywords("commands.previousSession.keywords"),
shortcut: { key: "[", meta: true, shift: true }, shortcut: { key: "[", meta: true, shift: true },
action: () => { action: () => {
const instanceId = activeInstanceId() const instanceId = activeInstanceId()
@@ -223,10 +231,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "compact", id: "compact",
label: "Compact Session", label: () => tGlobal("commands.compactSession.label"),
description: "Summarize and compact the current session", description: () => tGlobal("commands.compactSession.description"),
category: "Session", category: "Session",
keywords: ["/compact", "summarize", "compress"], keywords: () => ["/compact", ...splitKeywords("commands.compactSession.keywords")],
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
@@ -247,9 +255,9 @@ export function useCommands(options: UseCommandsOptions) {
) )
} catch (error) { } catch (error) {
log.error("Failed to compact session", error) log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session" const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback")
showAlertDialog(`Compact failed: ${message}`, { showAlertDialog(tGlobal("commands.compactSession.alert.message", { message }), {
title: "Compact failed", title: tGlobal("commands.compactSession.alert.title"),
variant: "error", variant: "error",
}) })
} }
@@ -275,10 +283,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "undo", id: "undo",
label: "Undo Last Message", label: () => tGlobal("commands.undoLastMessage.label"),
description: "Revert the last message", description: () => tGlobal("commands.undoLastMessage.description"),
category: "Session", category: "Session",
keywords: ["/undo", "revert", "undo"], keywords: () => ["/undo", ...splitKeywords("commands.undoLastMessage.keywords")],
action: async () => { action: async () => {
const instance = activeInstance() const instance = activeInstance()
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
@@ -320,8 +328,8 @@ export function useCommands(options: UseCommandsOptions) {
} }
if (!messageID) { if (!messageID) {
showAlertDialog("Nothing to undo", { showAlertDialog(tGlobal("commands.undoLastMessage.none.message"), {
title: "No actions to undo", title: tGlobal("commands.undoLastMessage.none.title"),
variant: "info", variant: "info",
}) })
return return
@@ -351,8 +359,8 @@ export function useCommands(options: UseCommandsOptions) {
} }
} catch (error) { } catch (error) {
log.error("Failed to revert message", error) log.error("Failed to revert message", error)
showAlertDialog("Failed to revert message", { showAlertDialog(tGlobal("commands.undoLastMessage.failed.message"), {
title: "Undo failed", title: tGlobal("commands.undoLastMessage.failed.title"),
variant: "error", variant: "error",
}) })
} }
@@ -362,10 +370,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "open-model-selector", id: "open-model-selector",
label: "Open Model Selector", label: () => tGlobal("commands.openModelSelector.label"),
description: "Choose a different model", description: () => tGlobal("commands.openModelSelector.description"),
category: "Agent & Model", category: "Agent & Model",
keywords: ["model", "llm", "ai"], keywords: () => splitKeywords("commands.openModelSelector.keywords"),
shortcut: { key: "M", meta: true, shift: true }, shortcut: { key: "M", meta: true, shift: true },
action: () => { action: () => {
const instance = activeInstance() const instance = activeInstance()
@@ -376,10 +384,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "open-variant-selector", id: "open-variant-selector",
label: "Select Model Variant", label: () => tGlobal("commands.selectModelVariant.label"),
description: "Choose a thinking effort for the current model", description: () => tGlobal("commands.selectModelVariant.description"),
category: "Agent & Model", category: "Agent & Model",
keywords: ["variant", "thinking", "reasoning", "effort"], keywords: () => splitKeywords("commands.selectModelVariant.keywords"),
shortcut: { key: "T", meta: true, shift: true }, shortcut: { key: "T", meta: true, shift: true },
action: () => { action: () => {
const instance = activeInstance() const instance = activeInstance()
@@ -390,10 +398,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "open-agent-selector", id: "open-agent-selector",
label: "Open Agent Selector", label: () => tGlobal("commands.openAgentSelector.label"),
description: "Choose a different agent", description: () => tGlobal("commands.openAgentSelector.description"),
category: "Agent & Model", category: "Agent & Model",
keywords: ["agent", "mode"], keywords: () => splitKeywords("commands.openAgentSelector.keywords"),
shortcut: { key: "A", meta: true, shift: true }, shortcut: { key: "A", meta: true, shift: true },
action: () => { action: () => {
const instance = activeInstance() const instance = activeInstance()
@@ -404,10 +412,10 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "clear-input", id: "clear-input",
label: "Clear Input", label: () => tGlobal("commands.clearInput.label"),
description: "Clear the prompt textarea", description: () => tGlobal("commands.clearInput.description"),
category: "Input & Focus", category: "Input & Focus",
keywords: ["clear", "reset"], keywords: () => splitKeywords("commands.clearInput.keywords"),
shortcut: { key: "K", meta: true }, shortcut: { key: "K", meta: true },
action: () => { action: () => {
const textarea = findVisiblePromptTextarea() const textarea = findVisiblePromptTextarea()
@@ -417,19 +425,19 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "thinking", id: "thinking",
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`, label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: "Show/hide AI thinking process", description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System", category: "System",
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"], keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks, action: options.toggleShowThinkingBlocks,
}) })
commandRegistry.register({ commandRegistry.register({
id: "timeline-tools", id: "timeline-tools",
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`, label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: "Toggle tool call entries in the message timeline", description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System", category: "System",
keywords: ["timeline", "tool", "toggle"], keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools, action: options.toggleShowTimelineTools,
}) })
@@ -437,11 +445,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "thinking-default-visibility", id: "thinking-default-visibility",
label: () => { label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded" 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", category: "System",
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"], keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => { action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded" const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -451,19 +460,25 @@ export function useCommands(options: UseCommandsOptions) {
commandRegistry.register({ commandRegistry.register({
id: "diff-view-split", id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`, label: () => {
description: "Display tool-call diffs side-by-side", const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System", category: "System",
keywords: ["diff", "split", "view"], keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"), action: () => options.setDiffViewMode("split"),
}) })
commandRegistry.register({ commandRegistry.register({
id: "diff-view-unified", id: "diff-view-unified",
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`, label: () => {
description: "Display tool-call diffs inline", const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System", category: "System",
keywords: ["diff", "unified", "view"], keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"), action: () => options.setDiffViewMode("unified"),
}) })
@@ -471,11 +486,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "tool-output-default-visibility", id: "tool-output-default-visibility",
label: () => { label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded" 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", category: "System",
keywords: ["tool", "output", "expand", "collapse"], keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => { action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded" const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -487,11 +503,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "diagnostics-default-visibility", id: "diagnostics-default-visibility",
label: () => { label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded" 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", category: "System",
keywords: ["diagnostics", "expand", "collapse"], keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => { action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded" const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
@@ -503,11 +520,12 @@ export function useCommands(options: UseCommandsOptions) {
id: "token-usage-visibility", id: "token-usage-visibility",
label: () => { label: () => {
const visible = options.preferences().showUsageMetrics ?? true 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", category: "System",
keywords: ["token", "usage", "cost", "stats"], keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics, action: options.toggleUsageMetrics,
}) })
@@ -515,21 +533,21 @@ export function useCommands(options: UseCommandsOptions) {
id: "auto-cleanup-blank-sessions", id: "auto-cleanup-blank-sessions",
label: () => { label: () => {
const enabled = options.preferences().autoCleanupBlankSessions 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", category: "System",
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"], keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions, action: options.toggleAutoCleanupBlankSessions,
}) })
commandRegistry.register({ commandRegistry.register({
id: "help", id: "help",
label: "Show Help", label: () => tGlobal("commands.showHelp.label"),
description: () => tGlobal("commands.showHelp.description"),
description: "Display keyboard shortcuts and help",
category: "System", category: "System",
keywords: ["/help", "shortcuts", "help"], keywords: () => ["/help", ...splitKeywords("commands.showHelp.keywords")],
action: () => { action: () => {
log.info("Show help modal (not implemented)") 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 type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences" import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en" import { enMessages } from "./messages/en/index"
type Messages = Record<string, string> type Messages = Record<string, string>
export type TranslateParams = Record<string, unknown>
export type Locale = "en" export type Locale = "en"
const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const 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 { export interface I18nContextValue {
locale: () => Locale locale: () => Locale
t: (key: string, params?: Record<string, unknown>) => string t: (key: string, params?: TranslateParams) => string
} }
const I18nContext = createContext<I18nContextValue>() const I18nContext = createContext<I18nContextValue>()
@@ -68,6 +86,8 @@ export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig() const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en") const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
const previousMessages = globalMessages
onMount(() => { onMount(() => {
const detected = detectNavigatorLocale() const detected = detectNavigatorLocale()
if (detected) setDetectedLocale(detected) if (detected) setDetectedLocale(detected)
@@ -80,13 +100,20 @@ export const I18nProvider: ParentComponent = (props) => {
const messages = createMemo<Messages>(() => messagesByLocale[locale()]) const messages = createMemo<Messages>(() => messagesByLocale[locale()])
function t(key: string, params?: Record<string, unknown>): string { function t(key: string, params?: TranslateParams): string {
const current = messages()[key] return translateFrom(messages(), key, params)
const fallback = enMessages[key as keyof typeof enMessages]
const template = current ?? fallback ?? key
return interpolate(template, params)
} }
createEffect(() => {
globalMessages = messages()
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
globalMessages = previousMessages
setGlobalRevision((value) => value + 1)
})
const value: I18nContextValue = { const value: I18nContextValue = {
locale, locale,
t, 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.logoAlt": "CodeNomad logo",
"folderSelection.tagline": "Select a folder to start coding with AI", "folderSelection.tagline": "Select a folder to start coding with AI",
@@ -31,9 +31,4 @@ export const enMessages = {
"folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.", "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 } 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 { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full" import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger" import { getLogger } from "./logger"
import { tGlobal } from "./i18n"
const log = getLogger("actions") const log = getLogger("actions")
@@ -259,19 +260,20 @@ function setupRenderer(isDark: boolean) {
// Use "text" as default when no language is specified // Use "text" as default when no language is specified
const resolvedLang = lang && lang.trim() ? lang.trim() : "text" const resolvedLang = lang && lang.trim() ? lang.trim() : "text"
const escapedLang = escapeHtml(resolvedLang) const escapedLang = escapeHtml(resolvedLang)
const copyLabel = escapeHtml(tGlobal("markdown.copy"))
const header = ` const header = `
<div class="code-block-header"> <div class="code-block-header">
<span class="code-block-language">${escapedLang}</span> <span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}"> <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"> <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> <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> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg> </svg>
<span class="copy-text">Copy</span> <span class="copy-text">${copyLabel}</span>
</button> </button>
</div> </div>
`.trim() `.trim()
if (highlightSuppressed) { 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>` return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`

View File

@@ -1,22 +1,25 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js" import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png" import iconUrl from "../../images/CodeNomad-Icon.png"
import { tGlobal } from "../../lib/i18n"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env" import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css" import "../../index.css"
import "./loading.css" import "./loading.css"
const phrases = [ const phraseKeys = [
"Warming up the AI neurons", "loadingScreen.phrases.neurons",
"Convincing the AI to stop daydreaming", "loadingScreen.phrases.daydreaming",
"Polishing the AIs code goggles", "loadingScreen.phrases.goggles",
"Asking the AI to stop reorganizing your files", "loadingScreen.phrases.reorganizingFiles",
"Feeding the AI additional coffee", "loadingScreen.phrases.coffee",
"Teaching the AI not to delete node_modules (again)…", "loadingScreen.phrases.nodeModules",
"Telling the AI to act natural before you arrive…", "loadingScreen.phrases.actNatural",
"Asking the AI to please stop rewriting history", "loadingScreen.phrases.rewritingHistory",
"Letting the AI stretch before its coding sprint…", "loadingScreen.phrases.stretch",
"Persuading the AI to give you keyboard control", "loadingScreen.phrases.keyboardControl",
] ] as const
type PhraseKey = (typeof phraseKeys)[number]
interface CliStatus { interface CliStatus {
state?: string state?: string
@@ -31,9 +34,9 @@ interface TauriBridge {
} }
} }
function pickPhrase(previous?: string) { function pickPhraseKey(previous?: PhraseKey) {
const filtered = phrases.filter((phrase) => phrase !== previous) const filtered = phraseKeys.filter((key) => key !== previous)
const source = filtered.length > 0 ? filtered : phrases const source = filtered.length > 0 ? filtered : phraseKeys
const index = Math.floor(Math.random() * source.length) const index = Math.floor(Math.random() * source.length)
return source[index] return source[index]
} }
@@ -63,15 +66,15 @@ function annotateDocument() {
} }
function LoadingApp() { function LoadingApp() {
const [phrase, setPhrase] = createSignal(pickPhrase()) const [phraseKey, setPhraseKey] = createSignal<PhraseKey>(pickPhraseKey())
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const [status, setStatus] = createSignal<string | null>(null) const [statusKey, setStatusKey] = createSignal<string | null>(null)
const changePhrase = () => setPhrase(pickPhrase(phrase())) const changePhrase = () => setPhraseKey(pickPhraseKey(phraseKey()))
onMount(() => { onMount(() => {
annotateDocument() annotateDocument()
setPhrase(pickPhrase()) setPhraseKey(pickPhraseKey())
const unsubscribers: Array<() => void> = [] const unsubscribers: Array<() => void> = []
async function bootstrapTauri(tauriBridge: TauriBridge | null) { async function bootstrapTauri(tauriBridge: TauriBridge | null) {
@@ -82,26 +85,26 @@ function LoadingApp() {
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => { const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
setError(null) setError(null)
setStatus(null) setStatusKey(null)
navigateTo(payload.url) navigateTo(payload.url)
}) })
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => { const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.error) { if (payload.error) {
setError(payload.error) setError(payload.error)
setStatus("Encountered an issue") setStatusKey("loadingScreen.status.issue")
} }
}) })
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => { const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) { if (payload.state === "error" && payload.error) {
setError(payload.error) setError(payload.error)
setStatus("Encountered an issue") setStatusKey("loadingScreen.status.issue")
return return
} }
if (payload.state && payload.state !== "ready") { if (payload.state && payload.state !== "ready") {
setError(null) setError(null)
setStatus(null) setStatusKey(null)
} }
}) })
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten) unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
@@ -111,11 +114,11 @@ function LoadingApp() {
navigateTo(result.url) navigateTo(result.url)
} else if (result?.state === "error" && result.error) { } else if (result?.state === "error" && result.error) {
setError(result.error) setError(result.error)
setStatus("Encountered an issue") setStatusKey("loadingScreen.status.issue")
} }
} catch (err) { } catch (err) {
setError(String(err)) setError(String(err))
setStatus("Encountered an issue") setStatusKey("loadingScreen.status.issue")
} }
} }
@@ -136,19 +139,21 @@ function LoadingApp() {
return ( return (
<div class="loading-wrapper" role="status" aria-live="polite"> <div class="loading-wrapper" role="status" aria-live="polite">
<img src={iconUrl} alt="CodeNomad" class="loading-logo" width="180" height="180" /> <img src={iconUrl} alt={tGlobal("loadingScreen.logoAlt")} class="loading-logo" width="180" height="180" />
<div class="loading-heading"> <div class="loading-heading">
<h1 class="loading-title">CodeNomad</h1> <h1 class="loading-title">CodeNomad</h1>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show> <Show when={statusKey()}>
{(key) => <p class="loading-status">{tGlobal(key())}</p>}
</Show>
</div> </div>
<div class="loading-card"> <div class="loading-card">
<div class="loading-row"> <div class="loading-row">
<div class="spinner" aria-hidden="true" /> <div class="spinner" aria-hidden="true" />
<span>{phrase()}</span> <span>{tGlobal(phraseKey())}</span>
</div> </div>
<div class="phrase-controls"> <div class="phrase-controls">
<button type="button" onClick={changePhrase}> <button type="button" onClick={changePhrase}>
Show another {tGlobal("loadingScreen.actions.showAnother")}
</button> </button>
</div> </div>
{error() && <div class="loading-error">{error()}</div>} {error() && <div class="loading-error">{error()}</div>}
@@ -160,7 +165,7 @@ function LoadingApp() {
const root = document.getElementById("loading-root") const root = document.getElementById("loading-root")
if (!root) { if (!root) {
throw new Error("Loading root element not found") throw new Error(tGlobal("loadingScreen.errors.missingRoot"))
} }
render(() => <LoadingApp />, root) render(() => <LoadingApp />, root)

View File

@@ -3,6 +3,7 @@ import type { SupportMeta } from "../../../server/src/api-types"
import { getServerMeta } from "../lib/server-meta" import { getServerMeta } from "../lib/server-meta"
import { showToastNotification, ToastHandle } from "../lib/notifications" import { showToastNotification, ToastHandle } from "../lib/notifications"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { tGlobal } from "../lib/i18n"
import { hasInstances, showFolderSelection } from "./ui" import { hasInstances, showFolderSelection } from "./ui"
const log = getLogger("actions") const log = getLogger("actions")
@@ -42,16 +43,16 @@ function ensureVisibilityEffect() {
if (!activeToast || activeToastKey !== key) { if (!activeToast || activeToastKey !== key) {
dismissActiveToast() dismissActiveToast()
activeToast = showToastNotification({ activeToast = showToastNotification({
title: support.message ?? "Upgrade required", title: support.message ?? tGlobal("releases.upgradeRequired.title"),
message: support.latestServerVersion message: support.latestServerVersion
? `Update to CodeNomad ${support.latestServerVersion} to use the latest UI.` ? tGlobal("releases.upgradeRequired.message.withVersion", { version: support.latestServerVersion })
: "Update CodeNomad to use the latest UI.", : tGlobal("releases.upgradeRequired.message.noVersion"),
variant: "info", variant: "info",
duration: Number.POSITIVE_INFINITY, duration: Number.POSITIVE_INFINITY,
position: "bottom-right", position: "bottom-right",
action: support.latestServerUrl action: support.latestServerUrl
? { ? {
label: "Get update", label: tGlobal("releases.upgradeRequired.action.getUpdate"),
href: support.latestServerUrl, href: support.latestServerUrl,
} }
: undefined, : undefined,

View File

@@ -34,6 +34,7 @@ import { createClientSession, mapSdkSessionStatus, type Session, type SessionSta
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { tGlobal } from "../lib/i18n"
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
import { import {
@@ -308,7 +309,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
const newSession = { const newSession = {
id: info.id, id: info.id,
instanceId, instanceId,
title: info.title || "Untitled", title: info.title || tGlobal("sessionList.session.untitled"),
parentId: info.parentID || null, parentId: info.parentID || null,
agent: "", agent: "",
model: { model: {
@@ -415,10 +416,11 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
const label = session?.title?.trim() ? session.title : sessionID const label = session?.title?.trim() ? session.title : sessionID
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
const displayLabel = label ? `"${label}"` : sessionID
showToastNotification({ showToastNotification({
title: instanceName, title: instanceName,
message: `Session ${label ? `"${label}"` : sessionID} was compacted`, message: tGlobal("sessionEvents.sessionCompactedToast", { label: displayLabel }),
variant: "info", variant: "info",
duration: 10000, duration: 10000,
}) })
@@ -428,7 +430,7 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
const error = event.properties?.error const error = event.properties?.error
log.error(`[SSE] Session error:`, error) log.error(`[SSE] Session error:`, error)
let message = "Unknown error" let message = tGlobal("sessionEvents.sessionError.unknown")
if (error) { if (error) {
if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) { if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
@@ -438,8 +440,8 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
} }
} }
showAlertDialog(`Error: ${message}`, { showAlertDialog(tGlobal("sessionEvents.sessionError.message", { message }), {
title: "Session error", title: tGlobal("sessionEvents.sessionError.title"),
variant: "error", variant: "error",
}) })
} }

View File

@@ -8,6 +8,7 @@ import { instances } from "./instances"
import { showConfirmDialog } from "./alerts" import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api" import { requestData } from "../lib/opencode-api"
import { tGlobal } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -650,12 +651,12 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
if (fetchIfNeeded) { if (fetchIfNeeded) {
const confirmed = await showConfirmDialog( const confirmed = await showConfirmDialog(
"This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?", tGlobal("sessionState.cleanup.deepConfirm.message"),
{ {
title: "Deep Clean Sessions", title: tGlobal("sessionState.cleanup.deepConfirm.title"),
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.", detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
confirmLabel: "Continue", confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
cancelLabel: "Cancel" cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
} }
) )
if (!confirmed) return if (!confirmed) return
@@ -680,7 +681,9 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
if (deletedCount > 0) { if (deletedCount > 0) {
showToastNotification({ showToastNotification({
message: `Cleaned up ${deletedCount} blank session${deletedCount === 1 ? "" : "s"}`, message: deletedCount === 1
? tGlobal("sessionState.cleanup.toast.one", { count: deletedCount })
: tGlobal("sessionState.cleanup.toast.other", { count: deletedCount }),
variant: "info" variant: "info"
}) })
} }