Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
selection or a file. `lich sessions --json` and `whoami --json` now also carry
each session's `path` and `projectPath`, and `send --project` now takes a
directory, as `lich --help` already said it did.
- **Simplified Chinese (简体中文) joins the language settings.** Both Settings ›
Appearance pickers, the *Interface language* and the *Prompt language*, now
offer Simplified Chinese next to English, Brazilian Portuguese and
Spanish. A browser set to any Chinese variant picks it by default for the
interface.

- **Two language settings, English and Brazilian Portuguese.** Settings ›
Appearance now has an *Interface language* for lich's own screens and a
Expand Down
2 changes: 1 addition & 1 deletion docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ release before it did.

`LICH_PROMPT_LANG` is the seventh, set on every session with a transport: the
language the user picked for the text lich hands agents (Settings, "Prompt
language"), as a BCP 47 tag: `en`, `pt-BR` or `es` today. Anything that composes text
language"), as a BCP 47 tag: `en`, `pt-BR`, `es` or `zh-CN` today. Anything that composes text
an agent reads from inside the session (the `lich` CLI, its MCP server, a hook)
writes it in this language. An absent or unknown tag means English, so a plugin
older or newer than the lich it runs under keeps working. It is read at spawn:
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/common/Trans.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server"
import { afterEach, describe, expect, it, vi } from "vitest"
import { resetLocale, setLocale } from "@/lib/i18n/i18n"
import { zhCN } from "@/lib/i18n/locales/zh-cn"
import { Trans } from "./Trans"

afterEach(() => {
Expand Down Expand Up @@ -29,4 +30,11 @@ describe("Trans", () => {
const html = renderToStaticMarkup(<Trans k="common.count.file" params={{ count: 2 }} />)
expect(html).toBe("2 archivos")
})

it("renders Simplified Chinese", () => {
vi.stubGlobal("localStorage", { setItem: () => {}, getItem: () => null })
setLocale("zh-CN")
const html = renderToStaticMarkup(<Trans k="common.count.file" params={{ count: 2 }} />)
expect(html).toBe(zhCN.common.count.file.other.replace("{count}", "2"))
})
})
20 changes: 19 additions & 1 deletion frontend/src/lib/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { en } from "./locales/en"
import { es } from "./locales/es"
import { ptBR } from "./locales/pt-br"
import { zhCN } from "./locales/zh-cn"

function stubStorage(initial: Record<string, string> = {}) {
const data = new Map(Object.entries(initial))
Expand Down Expand Up @@ -102,6 +103,23 @@ describe("t", () => {
expect(t("common.count.file", { count: 2 })).toBe("2 arquivos")
})

it("renders Simplified Chinese with its single plural form", () => {
stubStorage()
setLocale("zh-CN")
const forms = zhCN.common.count.file
expect(forms.one).toBe(forms.other)
expect(t("common.count.file", { count: 1 })).toBe(forms.other.replace("{count}", "1"))
expect(t("common.count.file", { count: 0 })).toBe(forms.other.replace("{count}", "0"))
expect(t("common.action.cancel")).toBe(zhCN.common.action.cancel)
expect(t("common.action.cancel")).not.toBe("Cancel")
})

it("matches any Chinese browser language to zh-CN", () => {
expect(matchLocale("zh-CN")).toBe("zh-CN")
expect(matchLocale("zh")).toBe("zh-CN")
expect(matchLocale("zh-Hans-CN")).toBe("zh-CN")
})

it("rejects misuse at compile time", () => {
// @ts-expect-error a key that does not exist
expect(() => t("sidebar.nothing.here")).toThrow()
Expand Down Expand Up @@ -135,7 +153,7 @@ const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/g)].map((m)
// translation that renames, drops or invents a placeholder.
describe("every locale against English", () => {
const source = leaves(en as unknown as Tree)
const locales = { "pt-BR": ptBR, es }
const locales = { "pt-BR": ptBR, es, "zh-CN": zhCN }

it("covers every locale the picker offers", () => {
expect(Object.keys(locales)).toEqual(LOCALES.filter((locale) => locale !== "en"))
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/lib/i18n/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import type { At, ParamsArg, PluralForms, Shape } from "./catalog"
import { en, type MessageKey, type Messages } from "./locales/en"
import { es } from "./locales/es"
import { ptBR } from "./locales/pt-br"
import { zhCN } from "./locales/zh-cn"

// The interface language. It is a page preference, not a workspace setting:
// it changes nothing the backend does, so it lives in localStorage like the
// theme. The language of the text lich types into agent sessions is a separate
// setting on purpose (prompt-language-store.ts, internal/prompt): people often
// run the interface in one language and talk to their agents in another.

export const LOCALES = ["en", "pt-BR", "es"] as const
export const LOCALES = ["en", "pt-BR", "es", "zh-CN"] as const
export type Locale = (typeof LOCALES)[number]

/** Each language named in itself, which is how someone who cannot read the
Expand All @@ -20,9 +21,10 @@ export const LOCALE_NAMES: Record<Locale, string> = {
en: "English",
"pt-BR": "Português (Brasil)",
es: "Español",
"zh-CN": "简体中文",
}

const CATALOGS: Record<Locale, Shape<Messages>> = { en, "pt-BR": ptBR, es }
const CATALOGS: Record<Locale, Shape<Messages>> = { en, "pt-BR": ptBR, es, "zh-CN": zhCN }

export const UI_LANGUAGE_PREF = "lich.appearance.language"

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/i18n/lib-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,13 @@ describe("messages built in plain modules follow the language", () => {
setLocale("pt-BR")
expect(formatAge(5 * 60_000)).toBe("5 min")
})

it("formats units and ages for Simplified Chinese", () => {
setLocale("zh-CN")
expect(COST_MISS_REASON["mixed-models"]).toMatch(/[\u4e00-\u9fff]/)
expect(formatAge(5 * 60_000)).toMatch(/^5\s?(分|min)/)
const now = new Date(2026, 0, 5, 12, 0, 0)
const at = Math.floor(now.getTime() / 1000) + 45 * 60
expect(timeUntil(at, now)).toContain("45")
})
})
43 changes: 43 additions & 0 deletions frontend/src/lib/i18n/locales/zh-cn/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Shape } from "../../catalog"
import type { common as en } from "../en/common"

export const common = {
count: {
file: { one: "{count} 个文件", other: "{count} 个文件" },
commit: { one: "{count} 个提交", other: "{count} 个提交" },
},
time: {
justNow: "刚刚",
},
action: {
cancel: "取消",
save: "保存",
create: "创建",
},
checkAgainButton: {
check: "重新检查",
checking: "正在检查…",
},
errorBoundary: {
stoppedRendering: "{label} 已停止渲染",
reload: "重新加载窗口",
retry: "重试",
},
stepper: {
default: "默认",
},
toolMissing: {
notInstalled: "未安装 {label}",
install: "安装 {bin}",
},
pickerDialog: {
navigate: "导航",
filter: "筛选",
close: "关闭",
},
ui: {
dialog: {
close: "关闭",
},
},
} satisfies Shape<typeof en>
88 changes: 88 additions & 0 deletions frontend/src/lib/i18n/locales/zh-cn/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Shape } from "../../catalog"
import type { diff as en } from "../en/diff"

export const diff = {
binaryPreview: {
before: "之前",
after: "之后",
added: "已添加",
deleted: "已删除",
binary: "二进制文件",
binaryNoPreview: "二进制文件 · 此文件类型无法预览",
loading: "正在加载…",
missing: "不在此修订中",
pdfSide: "选择显示 PDF 的哪一侧",
},
commentBatch: {
openSession: "打开一个会话,将这些评论发送给它",
removeComment: "移除评论",
notePlaceholder: "关于整个更改的备注…",
noteLabel: "关于整个更改的备注",
count: { one: "{count} 条评论", other: "{count} 条评论" },
clear: "清空",
send: "发送",
},
diffBulk: {
collapseAll: "折叠所有文件",
expandAll: "展开所有文件",
showWhitespace: "显示空白字符更改",
hideWhitespace: "隐藏空白字符更改",
},
discardDialog: {
title: "放弃更改",
description: "要还原 {path} 的所有未提交更改吗?此操作无法撤销。",
descriptionAdded: "要还原 {path} 的所有未提交更改吗?该文件将从磁盘中删除。此操作无法撤销。",
confirm: "放弃更改",
},
injectMenu: {
injectFile: "注入文件",
injectLines: "注入行",
injectLinesRange: "注入行 {lines}",
sessionComment: "给会话的评论…",
sessionCommentRange: "给会话的评论 {lines}…",
reviewComment: "对拉取请求的评论…",
reviewCommentRange: "对拉取请求的评论 {lines}…",
revertSelected: "还原所选行",
revert: { one: "还原 {count} 行更改", other: "还原 {count} 行更改" },
},
fileDiff: {
readFailed: "无法读取 {path}",
whitespaceOnly: "仅空白字符",
onlyWhitespaceChanged: "仅空白字符发生了更改",
addAsContext: "将文件添加为上下文",
discard: "放弃更改",
viewed: "已查看",
},
reviewPanel: {
lastTurnHint: "上一轮运行期间磁盘上发生的更改,包括格式化工具、编辑器或你自己所做的编辑。",
discardFailed: "放弃更改失败:{error}",
reverted: "已还原 {name} 中的 +{added} -{deleted}",
undo: "撤销",
undoFailed: "无法撤销对 {name} 的还原",
revertFailed: "无法还原 {name} 中的行",
said: "说",
sourceLabel: "选择要显示的更改",
workingTree: "工作区",
lastTurn: "上一轮",
ended: "{age}前结束",
turnReadFailed: "无法读取上一轮",
notRepository: "不是 git 仓库",
loading: "正在加载…",
emptyTitle: "此时间窗口内没有任何更改。",
emptyBody: "上一轮开始到结束期间,磁盘上没有文件发生变化。",
unrecordedTitle: "没有记录上一轮。",
unrecordedBody: "一轮在此结束后,这里会显示内容。",
lostTitle: "这一轮的记录已丢失。",
lostBody: "一轮在此结束,但 lich 无法为它保存检出的快照。下一轮会照常记录。",
noChanges: "没有未提交的更改",
squeezed: "并排视图需要更宽的面板",
},
reviewSlots: {
forSession: "给会话 · {lines}",
onPullRequest: "对拉取请求 · {lines}",
addToBatch: "添加到批次",
addToReview: "添加到审查",
sessionPlaceholder: "这里应该改什么?它会连同其他内容一起发送给会话。",
reviewPlaceholder: "留下评论。提交审查时一并发送。",
},
} satisfies Shape<typeof en>
37 changes: 37 additions & 0 deletions frontend/src/lib/i18n/locales/zh-cn/dock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Shape } from "../../catalog"
import type { dock as en } from "../en/dock"

export const dock = {
rightDock: {
fileBrowser: "文件浏览器",
reviewChanges: "审查更改",
tabCode: "代码",
tabReview: "审查",
exitFullScreen: "退出全屏",
fullScreen: "全屏",
closePanel: "关闭面板",
fileTree: "文件树",
reviewPanel: "审查面板",
resizePanel: "调整面板大小",
},
searchResults: {
prompt: "搜索此检出中每个文件的文本",
searching: "正在搜索…",
noMatch: "没有文件包含“{query}”",
},
filesPanel: {
searchPlaceholder: "在文件中搜索",
filterPlaceholder: "按名称筛选",
searchLabel: "在文件中搜索",
filterLabel: "按名称筛选文件",
matchesLabel: "输入框匹配的内容",
byName: "名称",
byText: "文本",
readFailed: "无法读取此文件夹",
loading: "正在加载…",
noMatch: "没有匹配的文件",
empty: "这里没有文件",
backToTree: "返回文件树",
readOnly: "只读",
},
} satisfies Shape<typeof en>
58 changes: 58 additions & 0 deletions frontend/src/lib/i18n/locales/zh-cn/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Shape } from "../../catalog"
import type { env as en } from "../en/env"

export const env = {
providerSummary: {
installed: "已安装",
installedCustomPath: "已安装 · 自定义路径",
signedOut: "已登出",
windowPercent: "{length} 窗口的 {percent}%",
percentUsed: "已用 {percent}%",
},
quota: {
tokenLogin: "令牌登录",
weekShort: "周",
},
paths: {
cwdUnknown: "工作目录未知 · 位于 {host} 内",
},
sandbox: {
windowsReason: "lich 在 Windows 上没有沙箱后端",
windowsAdvice: "无需安装任何东西,每个会话都直接在本机上运行。",
macReason: "sandbox-exec 不可用",
macAdvice:
"macOS 自带 /usr/bin/sandbox-exec,所以缺少可用的 sandbox-exec 意味着系统已损坏,lich 无法修复。每个会话都直接在本机上运行。",
linuxReason: "未安装 bubblewrap",
linuxAdvice: "请安装 bubblewrap 并重新打开 lich。在此之前,每个会话都直接在本机上运行。",
confinedMeans:
"一个只包含智能体自身状态的空主目录,本机只读,只能写入其检出目录内部。网络保持开启。",
},
vcsTools: {
gitWithout: "没有它,分支、差异和工作树都将为空。",
ghWithout: "没有它,无法使用拉取请求、检查和 PR 检出。",
},
binary: {
parkedProject: "{name} 覆盖已关闭",
parkedProjectFallback: "项目",
parkedGlobal: "全局覆盖已关闭",
executable: "可执行",
noSuchFile: "文件不存在",
notOnPath: "不在 $PATH 中",
notExecutable: "不可执行",
homeNotExpanded: "~ 不会被展开",
relativePath: "相对路径",
detailHomeShortcut:
"lich 直接启动该二进制文件,因此 ~ 按字面处理而不会被展开。请使用完整路径。",
detailRelative:
"相对路径会按每个会话自己的工作目录解析,因此每个会话指向的二进制文件各不相同。请使用完整路径。",
detailBroken: "问题解决之前,会话无法启动。你也可以关闭或清除覆盖设置,回退到下一层的配置。",
},
commitIdentity: {
noneLead: "此检出中没有 git 身份。",
noneNote: "在设置 user.email 之前,提交将被拒绝。",
landAsNamed: "提交将以 {name} 的身份记录",
landAs: "提交将以以下身份记录",
setLocal: ",在此仓库中设置,覆盖你的全局配置。",
setGlobal: ",来自 git user.email,而非此账户。",
},
} satisfies Shape<typeof en>
19 changes: 19 additions & 0 deletions frontend/src/lib/i18n/locales/zh-cn/footer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Shape } from "../../catalog"
import type { footer as en } from "../en/footer"

export const footer = {
item: {
attach: { label: "附加文件", example: "附加文件" },
files: { label: "文件资源管理器", example: "文件" },
changes: { label: "更改" },
pr: { label: "拉取请求" },
checkout: { label: "分支" },
path: { label: "工作目录" },
model: { label: "模型", example: "模型" },
context: { label: "上下文窗口" },
plan: { label: "套餐用量" },
cost: { label: "费用" },
handsOn: { label: "实际操作时间" },
clock: { label: "日期和时间" },
},
} satisfies Shape<typeof en>
Loading
Loading