Merge pull request #99 from NeuralNomadsAI/dev

Release v0.9.2 - Model Favourites and Multi-Lang UI
This commit is contained in:
Shantur Rathore
2026-01-26 21:02:29 +00:00
committed by GitHub
188 changed files with 7313 additions and 893 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.9.1", "version": "0.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
@@ -7384,7 +7384,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server" "@neuralnomads/codenomad": "file:../server"
@@ -7418,7 +7418,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",
@@ -7455,14 +7455,14 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.9.1", "version": "0.9.2",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
} }
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"workspaces": { "workspaces": {

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.9.1", "minServerVersion": "0.9.2",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.1", "version": "0.9.2",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",

View File

@@ -3,6 +3,6 @@
"version": "0.5.0", "version": "0.5.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.1.30" "@opencode-ai/plugin": "1.1.36"
} }
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.9.1", "version": "0.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.9.1", "version": "0.9.2",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",

View File

@@ -13,8 +13,10 @@ const PreferencesSchema = z.object({
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true), showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
locale: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]), modelRecents: z.array(ModelPreferenceSchema).default([]),
modelFavorites: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}), modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "tauri dev",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

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

@@ -1,5 +1,6 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -9,6 +10,7 @@ import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars" import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters" import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -23,13 +25,27 @@ interface FolderSelectionViewProps {
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences } = useConfig() const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs() const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string }
const languageOptions: LanguageOption[] = [
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
{ value: "fr", label: "Français" },
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders() const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading) const isLoading = () => Boolean(props.isLoading)
@@ -181,10 +197,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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 handleFolderSelect(path: string) { function handleFolderSelect(path: string) {
@@ -203,7 +219,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (nativeDialogsAvailable) { if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({ const selected = await openNativeFolderDialog({
title: "Select Workspace", title: t("folderSelection.dialog.title"),
defaultPath: fallbackPath, defaultPath: fallbackPath,
}) })
if (selected) { if (selected) {
@@ -253,6 +269,50 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden" class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"} aria-busy={isLoading() ? "true" : "false"}
> >
<div class="absolute top-4 left-6">
<Select<LanguageOption>
value={selectedLanguageOption()}
onChange={(value) => {
if (!value) return
if (value.value === locale()) return
updatePreferences({ locale: value.value })
}}
options={languageOptions}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger
class="selector-trigger"
aria-label={t("folderSelection.language.ariaLabel")}
title={t("folderSelection.language.ariaLabel")}
>
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
<div class="flex-1 min-w-0">
<Select.Value<LanguageOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover min-w-[180px]">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
<Show when={props.onOpenRemoteAccess}> <Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6"> <div class="absolute top-4 right-6">
<button <button
@@ -266,7 +326,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show> </Show>
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" /> <img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
</div> </div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2"> <div class="mt-3 flex justify-center gap-2">
@@ -275,8 +335,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label="CodeNomad GitHub" aria-label={t("folderSelection.links.github")}
title="CodeNomad GitHub" title={t("folderSelection.links.github")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
@@ -289,8 +349,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label="CodeNomad GitHub Stars" aria-label={t("folderSelection.links.githubStars")}
title="CodeNomad GitHub Stars" title={t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
@@ -306,8 +366,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label="CodeNomad Discord" aria-label={t("folderSelection.links.discord")}
title="CodeNomad Discord" title={t("folderSelection.links.discord")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink( openExternalLink(
@@ -318,7 +378,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<DiscordSymbolIcon class="w-4 h-4" /> <DiscordSymbolIcon class="w-4 h-4" />
</a> </a>
</div> </div>
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p> <p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
</div> </div>
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4"> <div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
@@ -332,16 +392,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="panel-empty-state-icon"> <div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" /> <Clock class="w-12 h-12 mx-auto" />
</div> </div>
<p class="panel-empty-state-title">No Recent Folders</p> <p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p> <p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div> </div>
} }
> >
<div class="panel flex flex-col flex-1 min-h-0"> <div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header"> <div class="panel-header">
<h2 class="panel-title">Recent Folders</h2> <h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle"> <p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available {t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p> </p>
</div> </div>
<div <div
@@ -393,7 +458,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClick={(e) => handleRemove(folder.path, e)} onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()} disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded" class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent" title={t("folderSelection.recent.remove")}
> >
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" /> <Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button> </button>
@@ -411,8 +476,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0"> <div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0"> <div class="panel shrink-0">
<div class="panel-header hidden sm:block"> <div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2> <h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">Select any folder on your computer</p> <p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@@ -424,7 +489,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" /> <FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span> <span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div> </div>
<Kbd shortcut="cmd+n" class="ml-2" /> <Kbd shortcut="cmd+n" class="ml-2" />
</button> </button>
@@ -435,7 +504,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between"> <button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" /> <Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span> <span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
</div> </div>
<ChevronRight class="w-4 h-4 icon-muted" /> <ChevronRight class="w-4 h-4 icon-muted" />
</button> </button>
@@ -457,20 +526,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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("folderSelection.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("folderSelection.hints.select")}</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>Remove</span> <span>{t("folderSelection.hints.remove")}</span>
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" /> <Kbd shortcut="cmd+n" />
<span>Browse</span> <span>{t("folderSelection.hints.browse")}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -480,8 +549,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="folder-loading-overlay"> <div class="folder-loading-overlay">
<div class="folder-loading-indicator"> <div class="folder-loading-indicator">
<div class="spinner" /> <div class="spinner" />
<p class="folder-loading-text">Starting instance</p> <p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p> <p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
</div> </div>
</div> </div>
</Show> </Show>
@@ -497,8 +566,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<DirectoryBrowserDialog <DirectoryBrowserDialog
open={isFolderBrowserOpen()} open={isFolderBrowserOpen()}
title="Select Workspace" title={t("folderSelection.dialog.title")}
description="Select workspace to start coding." description={t("folderSelection.dialog.description")}
onClose={() => setIsFolderBrowserOpen(false)} onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect} onSelect={handleBrowserSelect}
/> />

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

@@ -1,9 +1,11 @@
import { Combobox } from "@kobalte/core/combobox" import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo, createSignal } from "solid-js" 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, Star } 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 { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import Kbd from "./kbd" import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -22,10 +24,22 @@ 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)
const [manualAll, setManualAll] = createSignal(false)
const [explicitFavorites, setExplicitFavorites] = createSignal(false)
const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false)
const [searchDirty, setSearchDirty] = createSignal(false)
const [initialQuery, setInitialQuery] = createSignal("")
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
let triggerRef!: HTMLButtonElement let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement let searchInputRef!: HTMLInputElement
let listboxRef!: HTMLUListElement
let suppressNextClose = false
let wasFavoritesOnlyEnabled = false
let wasCurrentModelFavorite = false
createEffect(() => { createEffect(() => {
if (instanceProviders().length === 0) { if (instanceProviders().length === 0) {
@@ -44,61 +58,232 @@ export default function ModelSelector(props: ModelSelectorProps) {
), ),
) )
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of preferences().modelFavorites ?? []) {
if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`)
}
}
return result
})
const favoriteModels = createMemo<FlatModel[]>(() => {
const keys = favoriteKeySet()
if (keys.size === 0) return []
return allModels().filter((m) => keys.has(m.key))
})
const hasFavorites = createMemo(() => favoriteModels().length > 0)
const currentModelValue = createMemo(() => const currentModelValue = createMemo(() =>
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId), allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
) )
const currentModelIsFavorite = createMemo(() => {
const current = props.currentModel
return favoriteKeySet().has(`${current.providerId}/${current.modelId}`)
})
const currentModelKey = createMemo(() => {
const current = props.currentModel
return `${current.providerId}/${current.modelId}`
})
const searchActive = createMemo(() => {
if (!searchDirty()) return false
const next = inputValue().trim()
return next.length > 0
})
const favoritesOnlyEnabled = createMemo(() => {
if (searchActive()) return false
if (manualAll()) return false
if (!hasFavorites()) return false
return explicitFavorites() || autoFavoritesEligibleAtOpen()
})
const visibleOptions = createMemo<FlatModel[]>(() => {
if (!favoritesOnlyEnabled()) {
return allModels()
}
return favoriteModels()
})
const handleChange = async (value: FlatModel | null) => { const handleChange = async (value: FlatModel | null) => {
if (!value) return if (!value) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id }) await props.onModelChange({ providerId: value.providerId, modelId: value.id })
} }
const customFilter = (option: FlatModel, inputValue: string) => { const customFilter = (option: FlatModel, rawInput: string) => {
return option.searchText.toLowerCase().includes(inputValue.toLowerCase()) if (!searchDirty()) return true
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
} }
createEffect(() => { createEffect(() => {
if (isOpen()) { if (isOpen()) {
setManualAll(false)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
setSearchDirty(false)
setInitialQuery("")
setInputValue("")
setInitialQueryReady(false)
setTimeout(() => { setTimeout(() => {
const seeded = searchInputRef?.value ?? ""
setInitialQuery(seeded)
setInputValue(seeded)
setInitialQueryReady(true)
searchInputRef?.focus() searchInputRef?.focus()
searchInputRef?.select()
}, 100) }, 100)
} else {
setInitialQueryReady(false)
setSearchDirty(false)
setAutoFavoritesEligibleAtOpen(false)
} }
}) })
createEffect(() => {
if (!isOpen()) {
wasFavoritesOnlyEnabled = favoritesOnlyEnabled()
wasCurrentModelFavorite = currentModelIsFavorite()
return
}
const nowFavoritesOnlyEnabled = favoritesOnlyEnabled()
const nowCurrentModelFavorite = currentModelIsFavorite()
if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) {
setTimeout(() => {
const key = currentModelKey()
const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null
target?.scrollIntoView({ block: "nearest" })
}, 0)
}
wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled
wasCurrentModelFavorite = nowCurrentModelFavorite
})
const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
const next = event.currentTarget.value
setInputValue(next)
if (!initialQueryReady()) return
if (searchDirty()) return
if (next !== initialQuery()) {
setSearchDirty(true)
}
}
const preventListboxPress = (event: PointerEvent | MouseEvent) => {
event.preventDefault()
event.stopImmediatePropagation?.()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}
const toggleFavoritesOnly = () => {
if (!hasFavorites()) return
if (searchActive()) return
if (favoritesOnlyEnabled()) {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
return
}
setExplicitFavorites(true)
setManualAll(false)
}
const showAllModels = () => {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
setTimeout(() => searchInputRef?.focus(), 0)
}
return ( return (
<div class="sidebar-selector"> <div class="sidebar-selector">
<Combobox<FlatModel> <Combobox<FlatModel>
open={isOpen()}
value={currentModelValue()} value={currentModelValue()}
onChange={handleChange} onChange={handleChange}
onOpenChange={setIsOpen} onOpenChange={(next) => {
options={allModels()} if (!next && suppressNextClose) return
setIsOpen(next)
}}
options={visibleOptions()}
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) => {
<Combobox.Item const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
item={itemProps.item} return (
class="selector-option" <Combobox.Item
> item={itemProps.item}
<div class="selector-option-content"> class="selector-option"
<Combobox.ItemLabel class="selector-option-label"> >
{itemProps.item.rawValue.name} <>
</Combobox.ItemLabel> <div class="selector-option-content">
<Combobox.ItemDescription class="selector-option-description"> <Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/ <Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.id} {itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
</Combobox.ItemDescription> </Combobox.ItemDescription>
</div> </div>
<Combobox.ItemIndicator class="selector-option-indicator"> <Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</Combobox.ItemIndicator> </Combobox.ItemIndicator>
</Combobox.Item> <button
)} type="button"
class="selector-option-star"
data-active={isFavorite()}
aria-label={
isFavorite()
? t("modelSelector.favorite.remove")
: t("modelSelector.favorite.add")
}
onPointerDown={preventListboxPress}
onPointerUp={preventListboxPress}
onMouseDown={preventListboxPress}
onMouseUp={preventListboxPress}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: itemProps.item.rawValue.providerId,
modelId: itemProps.item.rawValue.id,
})
}}
>
<Star
class="w-4 h-4"
fill={isFavorite() ? "currentColor" : "none"}
/>
</button>
</>
</Combobox.Item>
)
}}
> >
<Combobox.Control class="relative w-full" data-model-selector-control> <Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector /> <Combobox.Input class="sr-only" data-model-selector />
@@ -108,7 +293,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">
@@ -128,13 +313,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content class="selector-popover"> <Combobox.Content class="selector-popover">
<div class="selector-search-container"> <div class="selector-search-container">
<Combobox.Input <div class="selector-input-group">
ref={searchInputRef} <Combobox.Input
class="selector-search-input" ref={searchInputRef}
placeholder="Search models..." class="selector-search-input flex-1 min-w-0"
/> placeholder={t("modelSelector.placeholder.search")}
onInput={handleSearchInput}
/>
<button
type="button"
class="selector-favorites-toggle"
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
aria-pressed={favoritesOnlyEnabled()}
disabled={!hasFavorites() || searchActive()}
data-active={favoritesOnlyEnabled()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoritesOnly()
}}
>
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
</button>
</div>
</div>
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
<div class="selector-footer">
<button
type="button"
class="selector-option selector-option-action w-full"
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
showAllModels()
}}
>
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
</button>
</div> </div>
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content> </Combobox.Content>
</Combobox.Portal> </Combobox.Portal>
</Combobox> </Combobox>

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

@@ -0,0 +1,148 @@
import { createContext, createEffect, createMemo, createSignal, onCleanup, onMount, useContext } from "solid-js"
import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en"
import { esMessages } from "./messages/es"
import { frMessages } from "./messages/fr"
import { ruMessages } from "./messages/ru"
import { jaMessages } from "./messages/ja"
import { zhHansMessages } from "./messages/zh-Hans"
type Messages = Record<string, string>
export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const messagesByLocale: Record<Locale, Messages> = {
en: enMessages,
es: esMessages,
fr: frMessages,
ru: ruMessages,
ja: jaMessages,
"zh-Hans": zhHansMessages,
}
function normalizeLocaleTag(value: string): string {
return value.trim().replace(/_/g, "-")
}
function matchSupportedLocale(value: string | undefined): Locale | null {
if (!value) return null
const normalized = normalizeLocaleTag(value)
const lower = normalized.toLowerCase()
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const exact = supportedLower.get(lower)
if (exact) return exact
const parts = lower.split("-")
const base = parts[0]
if (!base) return null
if (base === "zh") {
const zhHans = supportedLower.get("zh-hans")
return zhHans ?? null
}
const baseMatch = supportedLower.get(base)
return baseMatch ?? null
}
function detectNavigatorLocale(): Locale | null {
if (typeof navigator === "undefined") return null
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0
? navigator.languages
: navigator.language
? [navigator.language]
: []
for (const candidate of candidates) {
const match = matchSupportedLocale(candidate)
if (match) return match
}
return null
}
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 translateFrom(messages: Messages, key: string, params?: TranslateParams): string {
const current = messages[key]
const fallback = enMessages[key as keyof typeof enMessages]
const template = current ?? fallback ?? key
return interpolate(template, params)
}
const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision()
return translateFrom(globalMessages, key, params)
}
export interface I18nContextValue {
locale: () => Locale
t: (key: string, params?: TranslateParams) => string
}
const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
const previousMessages = globalMessages
onMount(() => {
const detected = detectNavigatorLocale()
if (detected) setDetectedLocale(detected)
})
const locale = createMemo<Locale>(() => {
const configured = matchSupportedLocale(preferences().locale)
return configured ?? detectedLocale() ?? "en"
})
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params)
}
createEffect(() => {
globalMessages = messages()
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
globalMessages = previousMessages
setGlobalRevision((value) => value + 1)
})
const value: I18nContextValue = {
locale,
t,
}
return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}

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,32 @@
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",
"releases.uiUpdated.title": "UI updated",
"releases.uiUpdated.message": "UI is now updated to {version}.",
} 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

@@ -0,0 +1,36 @@
export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "Language",
"folderSelection.logoAlt": "CodeNomad logo",
"folderSelection.tagline": "Select a folder to start coding with AI",
"folderSelection.links.github": "CodeNomad GitHub",
"folderSelection.links.githubStars": "CodeNomad GitHub Stars",
"folderSelection.links.discord": "CodeNomad Discord",
"folderSelection.empty.title": "No Recent Folders",
"folderSelection.empty.description": "Browse for a folder to get started",
"folderSelection.recent.title": "Recent Folders",
"folderSelection.recent.subtitle.one": "{count} folder available",
"folderSelection.recent.subtitle.other": "{count} folders available",
"folderSelection.recent.remove": "Remove from recent",
"folderSelection.browse.title": "Browse for Folder",
"folderSelection.browse.subtitle": "Select any folder on your computer",
"folderSelection.browse.button": "Browse Folders",
"folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.hints.navigate": "Navigate",
"folderSelection.hints.select": "Select",
"folderSelection.hints.remove": "Remove",
"folderSelection.hints.browse": "Browse",
"folderSelection.loading.title": "Starting instance...",
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
"folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.",
} 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,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,58 @@
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}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Toggle favorites only",
"modelSelector.favoritesOnly.showAll": "Show all models",
"modelSelector.favorite.add": "Add to favorites",
"modelSelector.favorite.remove": "Remove from favorites",
"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

@@ -0,0 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "Configuración avanzada",
"advancedSettings.environmentVariables.title": "Variables de entorno",
"advancedSettings.environmentVariables.subtitle": "Se aplican cada vez que inicia una nueva instancia de OpenCode",
"advancedSettings.actions.close": "Cerrar",
} as const

View File

@@ -0,0 +1,29 @@
export const appMessages = {
"app.launchError.title": "No se pudo iniciar OpenCode",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
"app.launchError.binaryPathLabel": "Ruta del binario",
"app.launchError.errorOutputLabel": "Salida de error",
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
"app.launchError.close": "Cerrar",
"app.launchError.closeTitle": "Cerrar (Esc)",
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
"app.stopInstance.confirmMessage": "¿Detener la instancia de OpenCode? Esto detendrá el servidor.",
"app.stopInstance.title": "Detener instancia",
"app.stopInstance.confirmLabel": "Detener",
"app.stopInstance.cancelLabel": "Seguir ejecutándose",
"emptyState.logoAlt": "Logo de CodeNomad",
"emptyState.brandTitle": "CodeNomad",
"emptyState.tagline": "Selecciona una carpeta para empezar a programar con IA",
"emptyState.actions.selectFolder": "Seleccionar carpeta",
"emptyState.actions.selecting": "Seleccionando...",
"emptyState.keyboardShortcut": "Atajo de teclado: {shortcut}",
"emptyState.examples": "Ejemplos: {example}",
"emptyState.multipleInstances": "Puedes tener varias instancias de la misma carpeta",
"releases.upgradeRequired.title": "Actualización requerida",
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
} as const

View File

@@ -0,0 +1,160 @@
export const commandMessages = {
"commandPalette.title": "Paleta de comandos",
"commandPalette.description": "Busca y ejecuta comandos",
"commandPalette.searchPlaceholder": "Escribe un comando o busca...",
"commandPalette.empty": "No se encontraron comandos para \"{query}\"",
"commandPalette.category.customCommands": "Comandos personalizados",
"commandPalette.category.instance": "Instancia",
"commandPalette.category.session": "Sesión",
"commandPalette.category.agentModel": "Agente y modelo",
"commandPalette.category.inputFocus": "Entrada y foco",
"commandPalette.category.system": "Sistema",
"commandPalette.category.other": "Otro",
"commands.newInstance.label": "Nueva instancia",
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
"commands.closeInstance.label": "Cerrar instancia",
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
"commands.closeInstance.keywords": "detener, salir, cerrar",
"commands.nextInstance.label": "Siguiente instancia",
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
"commands.nextInstance.keywords": "cambiar, navegar",
"commands.previousInstance.label": "Instancia anterior",
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
"commands.previousInstance.keywords": "cambiar, navegar",
"commands.newSession.label": "Nueva sesión",
"commands.newSession.description": "Crear una nueva sesión principal",
"commands.newSession.keywords": "crear, iniciar",
"commands.closeSession.label": "Cerrar sesión",
"commands.closeSession.description": "Cerrar la sesión principal actual",
"commands.closeSession.keywords": "cerrar, detener",
"commands.scrubSessions.label": "Depurar sesiones",
"commands.scrubSessions.description": "Eliminar sesiones vacías, sesiones de subagente que ya completaron su tarea principal y sesiones bifurcadas innecesarias.",
"commands.scrubSessions.keywords": "limpieza, blanco, vacías, sesiones, quitar, eliminar, depurar",
"commands.instanceInfo.label": "Info de la instancia",
"commands.instanceInfo.description": "Abrir el resumen de la instancia para ver logs y estado",
"commands.instanceInfo.keywords": "info, logs, consola, salida",
"commands.nextSession.label": "Siguiente sesión",
"commands.nextSession.description": "Cambiar a la siguiente pestaña de sesión",
"commands.nextSession.keywords": "cambiar, navegar",
"commands.previousSession.label": "Sesión anterior",
"commands.previousSession.description": "Cambiar a la pestaña de sesión anterior",
"commands.previousSession.keywords": "cambiar, navegar",
"commands.compactSession.label": "Compactar sesión",
"commands.compactSession.description": "Resumir y compactar la sesión actual",
"commands.compactSession.keywords": "resumir, comprimir",
"commands.compactSession.errorFallback": "No se pudo compactar la sesión",
"commands.compactSession.alert.title": "La compactación falló",
"commands.compactSession.alert.message": "La compactación falló: {message}",
"commands.undoLastMessage.label": "Deshacer último mensaje",
"commands.undoLastMessage.description": "Revertir el último mensaje",
"commands.undoLastMessage.keywords": "revertir, deshacer",
"commands.undoLastMessage.none.title": "No hay acciones para deshacer",
"commands.undoLastMessage.none.message": "Nada que deshacer",
"commands.undoLastMessage.failed.title": "No se pudo deshacer",
"commands.undoLastMessage.failed.message": "No se pudo revertir el mensaje",
"commands.openModelSelector.label": "Abrir selector de modelo",
"commands.openModelSelector.description": "Elegir un modelo diferente",
"commands.openModelSelector.keywords": "modelo, llm, IA",
"commands.selectModelVariant.label": "Seleccionar variante del modelo",
"commands.selectModelVariant.description": "Elegir un nivel de esfuerzo de pensamiento para el modelo actual",
"commands.selectModelVariant.keywords": "variante, pensamiento, razonamiento, esfuerzo",
"commands.openAgentSelector.label": "Abrir selector de agente",
"commands.openAgentSelector.description": "Elegir un agente diferente",
"commands.openAgentSelector.keywords": "agente, modo",
"commands.clearInput.label": "Limpiar entrada",
"commands.clearInput.description": "Borrar el área de texto del prompt",
"commands.clearInput.keywords": "limpiar, reiniciar",
"commands.thinkingBlocks.label.show": "Mostrar bloques de pensamiento",
"commands.thinkingBlocks.label.hide": "Ocultar bloques de pensamiento",
"commands.thinkingBlocks.description": "Mostrar/ocultar el proceso de pensamiento de la IA",
"commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar",
"commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo",
"commands.timelineToolCalls.label.hide": "Ocultar llamadas de herramienta en la línea de tiempo",
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
"commands.common.expanded": "Expandido",
"commands.common.collapsed": "Colapsado",
"commands.common.visible": "Visible",
"commands.common.hidden": "Oculto",
"commands.common.enabled": "Activado",
"commands.common.disabled": "Desactivado",
"commands.thinkingBlocksDefault.label": "Bloques de pensamiento por defecto · {state}",
"commands.thinkingBlocksDefault.description": "Alternar si los bloques de pensamiento empiezan expandidos",
"commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto",
"commands.diffViewSplit.label": "Usar vista de diff dividida",
"commands.diffViewSplit.description": "Mostrar diffs de llamadas de herramienta lado a lado",
"commands.diffViewSplit.keywords": "diff, dividir, vista",
"commands.diffViewUnified.label": "Usar vista de diff unificada",
"commands.diffViewUnified.description": "Mostrar diffs de llamadas de herramienta en línea",
"commands.diffViewUnified.keywords": "diff, unificada, vista",
"commands.toolOutputsDefault.label": "Salidas de herramientas por defecto · {state}",
"commands.toolOutputsDefault.description": "Alternar la expansión por defecto de las salidas de herramientas",
"commands.toolOutputsDefault.keywords": "herramienta, salida, expandir, colapsar",
"commands.diagnosticsDefault.label": "Diagnósticos por defecto · {state}",
"commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos",
"commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar",
"commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}",
"commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente",
"commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas",
"commands.autoCleanupBlankSessions.label": "Auto-limpieza de sesiones vacías · {state}",
"commands.autoCleanupBlankSessions.description": "Limpiar automáticamente las sesiones vacías al crear nuevas",
"commands.autoCleanupBlankSessions.keywords": "auto, limpieza, vacías, sesiones, alternar",
"commands.showHelp.label": "Mostrar ayuda",
"commands.showHelp.description": "Mostrar atajos de teclado y ayuda",
"commands.showHelp.keywords": "atajos, ayuda",
"commands.custom.argumentsPrompt.message": "Argumentos para /{name}",
"commands.custom.argumentsPrompt.title": "Comando personalizado",
"commands.custom.argumentsPrompt.inputLabel": "Argumentos",
"commands.custom.argumentsPrompt.inputPlaceholder": "p. ej. foo bar",
"commands.custom.argumentsPrompt.confirmLabel": "Ejecutar",
"commands.custom.argumentsPrompt.cancelLabel": "Cancelar",
"commands.custom.argumentsPrompt.openFailed.message": "No se pudo abrir el diálogo de argumentos.",
"commands.custom.argumentsPrompt.openFailed.title": "Argumentos del comando",
"commands.custom.entries.descriptionFallback": "Comando personalizado",
"commands.custom.sessionRequired.message": "Selecciona una sesión antes de ejecutar un comando personalizado.",
"commands.custom.sessionRequired.title": "Se requiere sesión",
"commands.custom.runFailed.message": "No se pudo ejecutar el comando personalizado. Revisa la consola para más detalles.",
"commands.custom.runFailed.title": "El comando falló",
"unifiedPicker.loading.searching": "Buscando...",
"unifiedPicker.loading.loadingWorkspace": "Cargando workspace...",
"unifiedPicker.title.command": "Seleccionar comando",
"unifiedPicker.title.mention": "Seleccionar agente o archivo",
"unifiedPicker.empty": "No se encontraron resultados",
"unifiedPicker.sections.commands": "COMANDOS",
"unifiedPicker.sections.agents": "AGENTES",
"unifiedPicker.sections.files": "ARCHIVOS",
"unifiedPicker.badge.subagent": "subagente",
"unifiedPicker.footer.navigate": "navegar",
"unifiedPicker.footer.select": "seleccionar",
"unifiedPicker.footer.close": "cerrar",
} as const

View File

@@ -0,0 +1,16 @@
export const dialogMessages = {
"alertDialog.fallbackTitle.info": "Aviso",
"alertDialog.fallbackTitle.warning": "Por favor revisa",
"alertDialog.fallbackTitle.error": "Algo salió mal",
"alertDialog.actions.confirm": "Confirmar",
"alertDialog.actions.run": "Ejecutar",
"alertDialog.actions.ok": "OK",
"alertDialog.actions.cancel": "Cancelar",
"alertDialog.prompt.inputLabel": "Entrada",
"backgroundProcessOutputDialog.title": "Salida en segundo plano",
"backgroundProcessOutputDialog.actions.close": "Cerrar",
"backgroundProcessOutputDialog.loading": "Cargando salida...",
"backgroundProcessOutputDialog.truncatedNotice": "Salida truncada para mostrar.",
"backgroundProcessOutputDialog.loadErrorFallback": "No se pudo cargar la salida.",
} as const

View File

@@ -0,0 +1,43 @@
export const filesystemMessages = {
"directoryBrowser.defaultDescription": "Explora carpetas bajo la raíz del workspace configurado.",
"directoryBrowser.close": "Cerrar",
"directoryBrowser.currentFolder": "Carpeta actual",
"directoryBrowser.selectCurrent": "Seleccionar actual",
"directoryBrowser.newFolder": "Nueva carpeta",
"directoryBrowser.creating": "Creando…",
"directoryBrowser.loadingFolders": "Cargando carpetas…",
"directoryBrowser.noFolders": "No hay carpetas disponibles.",
"directoryBrowser.upOneLevel": "Subir un nivel",
"directoryBrowser.select": "Seleccionar",
"directoryBrowser.load.errorFallback": "No se pudo cargar el sistema de archivos",
"directoryBrowser.createFolder.promptMessage": "Crea una nueva carpeta en el directorio actual.",
"directoryBrowser.createFolder.title": "Nueva carpeta",
"directoryBrowser.createFolder.inputLabel": "Nombre de la carpeta",
"directoryBrowser.createFolder.inputPlaceholder": "p. ej. mi-nuevo-proyecto",
"directoryBrowser.createFolder.confirmLabel": "Crear",
"directoryBrowser.createFolder.cancelLabel": "Cancelar",
"directoryBrowser.createFolder.invalidNameMessage": "Introduce un único nombre de carpeta.",
"directoryBrowser.createFolder.invalidNameDetail": "Los nombres de carpeta no pueden incluir barras, '..' ni '~'.",
"directoryBrowser.createFolder.errorFallback": "No se pudo crear la carpeta",
"filesystemBrowser.descriptionFallback": "Busca una ruta bajo la raíz del workspace configurado.",
"filesystemBrowser.rootLabel": "Raíz: {root}",
"filesystemBrowser.actions.close": "Cerrar",
"filesystemBrowser.actions.retry": "Reintentar",
"filesystemBrowser.actions.select": "Seleccionar",
"filesystemBrowser.filterLabel": "Filtro",
"filesystemBrowser.search.placeholder.directories": "Buscar carpetas",
"filesystemBrowser.search.placeholder.files": "Buscar archivos",
"filesystemBrowser.currentFolder.label": "Carpeta actual",
"filesystemBrowser.currentFolder.selectCurrent": "Seleccionar actual",
"filesystemBrowser.loading.filesystem": "sistema de archivos",
"filesystemBrowser.loading.workspaceRoot": "raíz del workspace",
"filesystemBrowser.loading.loadingWithPath": "Cargando {path}…",
"filesystemBrowser.empty.noEntries": "No se encontraron elementos.",
"filesystemBrowser.navigation.upOneLevel": "Subir un nivel",
"filesystemBrowser.hints.navigate": "Navegar",
"filesystemBrowser.hints.select": "Seleccionar",
"filesystemBrowser.hints.close": "Cerrar",
"filesystemBrowser.errors.loadFilesystemFallback": "No se pudo cargar el sistema de archivos",
"filesystemBrowser.errors.openDirectoryFallback": "No se pudo abrir el directorio",
} as const

View File

@@ -0,0 +1,36 @@
export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "Idioma",
"folderSelection.logoAlt": "Logo de CodeNomad",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
"folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
"folderSelection.links.discord": "Discord de CodeNomad",
"folderSelection.empty.title": "No hay carpetas recientes",
"folderSelection.empty.description": "Explora una carpeta para comenzar",
"folderSelection.recent.title": "Carpetas recientes",
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
"folderSelection.recent.remove": "Quitar de recientes",
"folderSelection.browse.title": "Explorar carpetas",
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
"folderSelection.browse.button": "Explorar carpetas",
"folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar",
"folderSelection.hints.remove": "Quitar",
"folderSelection.hints.browse": "Explorar",
"folderSelection.loading.title": "Iniciando instancia...",
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
"folderSelection.dialog.title": "Seleccionar workspace",
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
} 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 esMessages = mergeMessageParts(
folderSelectionMessages,
advancedSettingsMessages,
loadingScreenMessages,
timeMessages,
appMessages,
dialogMessages,
filesystemMessages,
instanceMessages,
logMessages,
sessionMessages,
messagingMessages,
toolCallMessages,
markdownMessages,
settingsMessages,
remoteAccessMessages,
commandMessages,
)

Some files were not shown because too many files have changed in this diff Show More