feat: add language selector with VS Code language detection + user override

- Extension sends vscode.env.language to webview via ready message
- LanguageProvider uses priority: user override → VS Code lang → browser → en
- LanguageTab has a Select dropdown with all 16 locales + 'Auto'
- Language preference stored in kilo-code.new.language VS Code setting
- setLanguage message from webview saves preference to VS Code config
This commit is contained in:
Mark IJbema
2026-02-11 14:53:46 +01:00
parent fa43276946
commit a528360a3f
8 changed files with 249 additions and 100 deletions
+1
View File
@@ -338,6 +338,7 @@
"name": "kilo-code",
"version": "0.0.1",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"eventsource": "^2.0.2",
+43
View File
@@ -114,6 +114,49 @@
"configuration": {
"title": "Kilo Code",
"properties": {
"kilo-code.new.language": {
"type": "string",
"default": "",
"description": "Override the UI language for Kilo Code (e.g. en, de, ja). Empty means use VS Code's display language.",
"enum": [
"",
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"ar",
"no",
"br",
"th",
"bs"
],
"enumDescriptions": [
"Auto (VS Code language)",
"English",
"简体中文",
"繁體中文",
"한국어",
"Deutsch",
"Español",
"Français",
"Dansk",
"日本語",
"Polski",
"Русский",
"العربية",
"Norsk",
"Português (Brasil)",
"ภาษาไทย",
"Bosanski"
]
},
"kilo-code.new.model.providerID": {
"type": "string",
"default": "kilo",
+15 -1
View File
@@ -64,9 +64,12 @@ export class KiloProvider implements vscode.WebviewViewProvider {
// Re-send ready so the webview can recover after refresh.
if (serverInfo) {
const langConfig = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "ready",
serverInfo,
vscodeLanguage: vscode.env.language,
languageOverride: langConfig.get<string>("language"),
})
}
@@ -193,6 +196,11 @@ export class KiloProvider implements vscode.WebviewViewProvider {
case "requestAgents":
await this.fetchAndSendAgents()
break
case "setLanguage":
await vscode.workspace
.getConfiguration("kilo-code.new")
.update("language", message.locale || undefined, vscode.ConfigurationTarget.Global)
break
}
})
}
@@ -259,7 +267,13 @@ export class KiloProvider implements vscode.WebviewViewProvider {
this.connectionState = this.connectionService.getConnectionState()
if (serverInfo) {
this.postMessage({ type: "ready", serverInfo })
const langConfig = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "ready",
serverInfo,
vscodeLanguage: vscode.env.language,
languageOverride: langConfig.get<string>("language"),
})
}
this.postMessage({ type: "connectionState", state: this.connectionState })
+24 -18
View File
@@ -1,6 +1,5 @@
import { Component, createSignal, createMemo, Switch, Match, onMount, onCleanup } from "solid-js"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { LanguageProvider } from "./context/language"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { DataProvider } from "@kilocode/kilo-ui/context/data"
@@ -10,6 +9,7 @@ import { VSCodeProvider } from "./context/vscode"
import { ServerProvider, useServer } from "./context/server"
import { ProviderProvider } from "./context/provider"
import { SessionProvider, useSession } from "./context/session"
import { LanguageProvider } from "./context/language"
import { ChatView } from "./components/chat"
import SessionList from "./components/history/SessionList"
import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2"
@@ -37,13 +37,6 @@ const DummyView: Component<{ title: string }> = (props) => {
/**
* Bridge our session store to the DataProvider's expected Data shape.
* Since the runtime data comes from the CLI backend, it's already in SDK format —
* we just need to restructure it into the Data layout that DataProvider expects.
*
* Note: DiffComponentProvider and CodeComponentProvider are NOT included because
* @pierre/diffs uses Vite's ?worker&url import syntax which is incompatible
* with esbuild. Tool renderers that need diffs (edit, write, apply_patch)
* will fall back to GenericTool display. See ui-implementation-plan.md §7.6.
*/
const DataBridge: Component<{ children: any }> = (props) => {
const session = useSession()
@@ -77,6 +70,19 @@ const DataBridge: Component<{ children: any }> = (props) => {
)
}
/**
* Wraps children in LanguageProvider, passing server-side language info.
* Must be below ServerProvider in the hierarchy.
*/
const LanguageBridge: Component<{ children: any }> = (props) => {
const server = useServer()
return (
<LanguageProvider vscodeLanguage={server.vscodeLanguage} languageOverride={server.languageOverride}>
{props.children}
</LanguageProvider>
)
}
// Inner app component that uses the contexts
const AppContent: Component = () => {
const [currentView, setCurrentView] = createSignal<ViewType>("newTask")
@@ -154,11 +160,11 @@ const AppContent: Component = () => {
const App: Component = () => {
return (
<ThemeProvider defaultTheme="kilo-vscode">
<LanguageProvider>
<DialogProvider>
<MarkedProvider>
<VSCodeProvider>
<ServerProvider>
<DialogProvider>
<VSCodeProvider>
<ServerProvider>
<LanguageBridge>
<MarkedProvider>
<ProviderProvider>
<SessionProvider>
<DataBridge>
@@ -166,11 +172,11 @@ const App: Component = () => {
</DataBridge>
</SessionProvider>
</ProviderProvider>
</ServerProvider>
</VSCodeProvider>
</MarkedProvider>
</DialogProvider>
</LanguageProvider>
</MarkedProvider>
</LanguageBridge>
</ServerProvider>
</VSCodeProvider>
</DialogProvider>
</ThemeProvider>
)
}
@@ -1,30 +1,34 @@
import { Component } from "solid-js"
import { Select } from "@kilocode/kilo-ui/select"
import { useLanguage, LOCALES, LOCALE_LABELS, type Locale } from "../../context/language"
const options = ["", ...LOCALES] as const
type Option = "" | Locale
const LanguageTab: Component = () => {
const language = useLanguage()
return (
<div>
<div
style={{
background: "var(--vscode-editor-background)",
border: "1px solid var(--vscode-panel-border)",
"border-radius": "4px",
padding: "16px",
<div style={{ padding: "16px" }}>
<p style={{ "font-size": "13px", "margin-bottom": "12px" }}>
Choose the language for the Kilo Code UI. "Auto" uses your VS Code display language.
</p>
<Select
options={[...options]}
current={language.userOverride()}
label={(opt: Option) => (opt === "" ? "Auto (VS Code language)" : LOCALE_LABELS[opt])}
value={(opt: Option) => opt}
onSelect={(opt) => {
if (opt !== undefined) {
language.setLocale(opt as Locale | "")
}
}}
>
<p
style={{
"font-size": "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
"line-height": "1.5",
}}
>
<strong style={{ color: "var(--vscode-foreground)" }}>This section is not implemented yet.</strong> It will
contain configuration options and explanatory text related to the selected settings category. During
reimplementation, use this space to validate layout, spacing, scrolling behavior, and navigation state before
wiring up real controls.
</p>
</div>
variant="secondary"
size="large"
/>
<p style={{ "font-size": "12px", color: "var(--vscode-descriptionForeground)", "margin-top": "8px" }}>
Current: {LOCALE_LABELS[language.locale()]}
</p>
</div>
)
}
@@ -2,10 +2,11 @@
* Language context
* Provides i18n translations for kilo-ui components.
* Merges UI translations from @opencode-ai/ui and Kilo overrides from @kilocode/kilo-i18n.
* Follows the same pattern as the desktop app (packages/app/src/context/language.tsx).
*
* Locale priority: user override → VS Code display language → browser language → "en"
*/
import { createMemo, ParentComponent } from "solid-js"
import { createSignal, createMemo, createEffect, ParentComponent, Accessor } from "solid-js"
import { I18nProvider } from "@kilocode/kilo-ui/context"
import type { UiI18nKey, UiI18nParams } from "@kilocode/kilo-ui/context"
import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en"
@@ -40,8 +41,9 @@ import { dict as kiloNo } from "@kilocode/kilo-i18n/no"
import { dict as kiloBr } from "@kilocode/kilo-i18n/br"
import { dict as kiloTh } from "@kilocode/kilo-i18n/th"
import { dict as kiloBs } from "@kilocode/kilo-i18n/bs"
import { useVSCode } from "./vscode"
type Locale =
export type Locale =
| "en"
| "zh"
| "zht"
@@ -59,7 +61,45 @@ type Locale =
| "th"
| "bs"
// Merge UI + Kilo dicts (kilo overrides last)
export const LOCALES: readonly Locale[] = [
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"ar",
"no",
"br",
"th",
"bs",
]
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
zh: "简体中文",
zht: "繁體中文",
ko: "한국어",
de: "Deutsch",
es: "Español",
fr: "Français",
da: "Dansk",
ja: "日本語",
pl: "Polski",
ru: "Русский",
ar: "العربية",
no: "Norsk",
br: "Português (Brasil)",
th: "ภาษาไทย",
bs: "Bosanski",
}
// Merge UI + Kilo dicts (kilo overrides last, English base always present)
const dicts: Record<Locale, Record<string, string>> = {
en: { ...uiEn, ...kiloEn },
zh: { ...uiEn, ...kiloEn, ...uiZh, ...kiloZh },
@@ -79,61 +119,23 @@ const dicts: Record<Locale, Record<string, string>> = {
bs: { ...uiEn, ...kiloEn, ...uiBs, ...kiloBs },
}
function detectLocale(): Locale {
if (typeof navigator !== "object") {
return "en"
function normalizeLocale(lang: string): Locale {
const lower = lang.toLowerCase()
if (lower.startsWith("zh")) {
return lower.includes("hant") ? "zht" : "zh"
}
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) {
continue
}
const lower = language.toLowerCase()
if (lower.startsWith("zh")) {
return lower.includes("hant") ? "zht" : "zh"
}
if (lower.startsWith("ko")) {
return "ko"
}
if (lower.startsWith("de")) {
return "de"
}
if (lower.startsWith("es")) {
return "es"
}
if (lower.startsWith("fr")) {
return "fr"
}
if (lower.startsWith("da")) {
return "da"
}
if (lower.startsWith("ja")) {
return "ja"
}
if (lower.startsWith("pl")) {
return "pl"
}
if (lower.startsWith("ru")) {
return "ru"
}
if (lower.startsWith("ar")) {
return "ar"
}
if (lower.startsWith("no") || lower.startsWith("nb") || lower.startsWith("nn")) {
return "no"
}
if (lower.startsWith("pt")) {
return "br"
}
if (lower.startsWith("th")) {
return "th"
}
if (lower.startsWith("bs")) {
return "bs"
for (const loc of LOCALES) {
if (lower.startsWith(loc)) {
return loc
}
}
// Special cases
if (lower.startsWith("nb") || lower.startsWith("nn")) {
return "no"
}
if (lower.startsWith("pt")) {
return "br"
}
return "en"
}
@@ -147,8 +149,39 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
})
}
export const LanguageProvider: ParentComponent = (props) => {
const locale = createMemo<Locale>(detectLocale)
interface LanguageProviderProps {
vscodeLanguage?: Accessor<string | undefined>
languageOverride?: Accessor<string | undefined>
}
export const LanguageProvider: ParentComponent<LanguageProviderProps> = (props) => {
const vscode = useVSCode()
const [userOverride, setUserOverride] = createSignal<Locale | "">("")
// Initialize from extension-side override
createEffect(() => {
const override = props.languageOverride?.()
if (override) {
setUserOverride(normalizeLocale(override))
}
})
// Resolved locale: user override → VS Code language → browser language → "en"
const locale = createMemo<Locale>(() => {
const override = userOverride()
if (override) {
return override
}
const vscodeLang = props.vscodeLanguage?.()
if (vscodeLang) {
return normalizeLocale(vscodeLang)
}
if (typeof navigator !== "undefined" && navigator.language) {
return normalizeLocale(navigator.language)
}
return "en"
})
const dict = createMemo(() => dicts[locale()] ?? dicts.en)
const t = (key: UiI18nKey, params?: UiI18nParams) => {
@@ -156,5 +189,33 @@ export const LanguageProvider: ParentComponent = (props) => {
return resolveTemplate(text, params)
}
return <I18nProvider value={{ locale: () => locale(), t }}>{props.children}</I18nProvider>
const setLocale = (next: Locale | "") => {
setUserOverride(next)
vscode.postMessage({ type: "setLanguage", locale: next })
}
return (
<LanguageContext.Provider value={{ locale, setLocale, userOverride }}>
<I18nProvider value={{ locale: () => locale(), t }}>{props.children}</I18nProvider>
</LanguageContext.Provider>
)
}
// Expose locale + setLocale for the LanguageTab
import { createContext, useContext } from "solid-js"
interface LanguageContextValue {
locale: Accessor<Locale>
setLocale: (locale: Locale | "") => void
userOverride: Accessor<Locale | "">
}
const LanguageContext = createContext<LanguageContextValue>()
export function useLanguage() {
const ctx = useContext(LanguageContext)
if (!ctx) {
throw new Error("useLanguage must be used within a LanguageProvider")
}
return ctx
}
@@ -15,6 +15,8 @@ interface ServerContextValue {
profileData: Accessor<ProfileData | null>
deviceAuth: Accessor<DeviceAuthState>
startLogin: () => void
vscodeLanguage: Accessor<string | undefined>
languageOverride: Accessor<string | undefined>
}
const ServerContext = createContext<ServerContextValue>()
@@ -29,6 +31,8 @@ export const ServerProvider: ParentComponent = (props) => {
const [error, setError] = createSignal<string | undefined>()
const [profileData, setProfileData] = createSignal<ProfileData | null>(null)
const [deviceAuth, setDeviceAuth] = createSignal<DeviceAuthState>(initialDeviceAuth)
const [vscodeLanguage, setVscodeLanguage] = createSignal<string | undefined>()
const [languageOverride, setLanguageOverride] = createSignal<string | undefined>()
onMount(() => {
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
@@ -38,6 +42,12 @@ export const ServerProvider: ParentComponent = (props) => {
setServerInfo(message.serverInfo)
setConnectionState("connected")
setError(undefined)
if (message.vscodeLanguage) {
setVscodeLanguage(message.vscodeLanguage)
}
if (message.languageOverride) {
setLanguageOverride(message.languageOverride)
}
break
case "connectionState":
@@ -110,6 +120,8 @@ export const ServerProvider: ParentComponent = (props) => {
profileData,
deviceAuth,
startLogin,
vscodeLanguage,
languageOverride,
}
return <ServerContext.Provider value={value}>{props.children}</ServerContext.Provider>
@@ -171,6 +171,8 @@ export interface ModelSelection {
export interface ReadyMessage {
type: "ready"
serverInfo?: ServerInfo
vscodeLanguage?: string
languageOverride?: string
}
export interface ConnectionStateMessage {
@@ -367,6 +369,11 @@ export interface RequestAgentsMessage {
type: "requestAgents"
}
export interface SetLanguageRequest {
type: "setLanguage"
locale: string
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -382,6 +389,7 @@ export type WebviewMessage =
| WebviewReadyRequest
| RequestProvidersMessage
| RequestAgentsMessage
| SetLanguageRequest
// ============================================
// VS Code API type