diff --git a/e2e/readme-screenshots.screenshot.ts b/e2e/readme-screenshots.screenshot.ts index 98e5708..fb4f85d 100644 --- a/e2e/readme-screenshots.screenshot.ts +++ b/e2e/readme-screenshots.screenshot.ts @@ -14,6 +14,7 @@ const showcaseDocument = readFileSync(path.join(showcaseDirectory, "index.html") const config = { version: 1, + language: "cn", pet: null, petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] }, window: { diff --git a/e2e/settings.e2e.ts b/e2e/settings.e2e.ts index 2b5c9be..b185937 100644 --- a/e2e/settings.e2e.ts +++ b/e2e/settings.e2e.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; const config = { version: 1, + language: "cn", pet: null, petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] }, window: { @@ -194,6 +195,28 @@ test("shows native pet directory paths and opens the default directory", async ( ).__TAURI_TEST_COMMANDS__)).toContain("reveal_pet_directory"); }); +test("switches languages without translating language names or native paths", async ({ page }) => { + await page.goto("/settings.html"); + const language = page.locator("#language"); + const directory = page.locator("#extra-directory"); + + await language.selectOption("en"); + await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); + await expect(page.locator("#current-version")).toHaveText("v1.1.0"); + await expect(language.locator('option[value="system"]')).toHaveText("Follow system"); + await expect(language.locator('option[value="cn"]')).toHaveText("简体中文"); + await expect(language.locator('option[value="en"]')).toHaveText("English"); + await expect(directory).toHaveAttribute("placeholder", "C:\\Users\\Tester\\Downloads\\codex-pets"); + + await language.selectOption("cn"); + await expect(page.getByRole("heading", { name: "通用" })).toBeVisible(); + await expect(page.locator("#current-version")).toHaveText("v1.1.0"); + await expect(language.locator('option[value="system"]')).toHaveText("跟随系统"); + await expect(language.locator('option[value="cn"]')).toHaveText("简体中文"); + await expect(language.locator('option[value="en"]')).toHaveText("English"); + await expect(directory).toHaveAttribute("placeholder", "C:\\Users\\Tester\\Downloads\\codex-pets"); +}); + test("shows an error when the default pet directory cannot be opened", async ({ page }) => { await page.goto("/settings.html"); await page.evaluate(() => ( @@ -271,6 +294,23 @@ test("downloads a signed update and offers installation", async ({ page }) => { await expect(page.locator("#about-update-dot")).toBeHidden(); }); +test("keeps the downloaded update state when switching languages", async ({ page }) => { + await page.goto("/settings.html#about"); + await page.getByRole("button", { name: "检查更新" }).click(); + await expect(page.locator("#update-status-card")).toHaveAttribute("data-state", "ready"); + + await page.getByRole("tab", { name: /通用/ }).click(); + await page.locator("#language").selectOption("en"); + await page.getByRole("tab", { name: /About/ }).click(); + + await expect(page.locator("#current-version")).toHaveText("v1.1.0"); + await expect(page.locator("#update-status-title")).toHaveText("v1.2.0 is ready"); + await expect(page.locator("#update-status-detail")).toHaveText( + "The update package was downloaded and passed signature verification. It is safe to install.", + ); + await expect(page.getByRole("button", { name: "Install and Restart" })).toBeVisible(); +}); + test("keeps the update status tile still while the progress ring spins", async ({ page }) => { await page.goto("/settings.html#about"); await page.locator("#update-status-card").evaluate((card) => { diff --git a/e2e/status.e2e.ts b/e2e/status.e2e.ts index 1b6a292..f2f6753 100644 --- a/e2e/status.e2e.ts +++ b/e2e/status.e2e.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"; const config = { version: 1, + language: "cn", pet: null, petSources: { scanCodexBuiltin: true, scanCodexCustom: true, extraDirectories: [] }, window: { diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index cb9ecb2..fea5e29 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -17,6 +17,8 @@ static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); #[serde(rename_all = "camelCase")] pub struct AppConfig { pub version: u8, + #[serde(default)] + pub language: LanguagePreference, pub pet: Option, pub pet_sources: PetSourcesConfig, pub window: WindowConfig, @@ -26,6 +28,15 @@ pub struct AppConfig { pub claude_code: ClaudeCodeConfig, } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LanguagePreference { + #[default] + System, + En, + Cn, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SelectedPet { @@ -117,6 +128,7 @@ impl Default for AppConfig { fn default() -> Self { Self { version: 1, + language: LanguagePreference::System, pet: None, pet_sources: PetSourcesConfig { scan_codex_builtin: true, @@ -258,7 +270,44 @@ pub fn ensure_private_dir(path: &Path) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use serde::de::{Error as _, IgnoredAny, MapAccess, Visitor}; + use serde::Deserializer as _; use serde_json::json; + use std::{collections::HashSet, fmt}; + + struct UniqueTranslationKeys; + + impl<'de> Visitor<'de> for UniqueTranslationKeys { + type Value = usize; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an i18n JSON object with unique top-level keys") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(A::Error::custom(format!("duplicate i18n key: {key}"))); + } + map.next_value::()?; + } + Ok(keys.len()) + } + } + + #[test] + fn i18n_message_keys_are_unique() { + let source = include_str!("../../src/i18n/messages.json"); + let mut deserializer = serde_json::Deserializer::from_str(source); + let key_count = deserializer + .deserialize_map(UniqueTranslationKeys) + .expect("i18n messages must be valid JSON with unique top-level keys"); + assert!(key_count > 0); + } #[test] fn new_display_fields_have_backward_compatible_defaults() { @@ -291,6 +340,7 @@ mod tests { })).unwrap(); assert!(!app.claude_code.hooks_enabled); assert!(app.claude_code.show_live_status); + assert_eq!(app.language, LanguagePreference::System); } #[cfg(unix)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bf7b06..312ede8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,7 +13,7 @@ mod window_drag; use base64::Engine; use config::{AppConfig, WindowConfig}; use pet_catalog::CatalogResult; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ fs, path::{Path, PathBuf}, @@ -44,10 +44,27 @@ fn set_debug_dock_icon() { struct TrayMenuState { show_pet: CheckMenuItem, + settings: MenuItem, always_on_top: CheckMenuItem, mouse_passthrough: CheckMenuItem, lock_position: CheckMenuItem, launch_at_login: CheckMenuItem, + quit: MenuItem, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NativeMessages { + settings_title: String, + status_title: String, + debug_title: String, + show_pet: String, + settings: String, + always_on_top: String, + mouse_passthrough: String, + lock_position: String, + launch_at_login: String, + quit: String, } struct StatusWindowState(Mutex); @@ -312,6 +329,45 @@ fn sync_tray_menu(app: &tauri::AppHandle, value: &WindowConfig) { } } +#[tauri::command] +fn sync_native_i18n(app: tauri::AppHandle, value: NativeMessages) -> Result<(), String> { + if let Some(menu) = app.try_state::() { + menu.show_pet + .set_text(value.show_pet) + .map_err(|error| error.to_string())?; + menu.settings + .set_text(value.settings) + .map_err(|error| error.to_string())?; + menu.always_on_top + .set_text(value.always_on_top) + .map_err(|error| error.to_string())?; + menu.mouse_passthrough + .set_text(value.mouse_passthrough) + .map_err(|error| error.to_string())?; + menu.lock_position + .set_text(value.lock_position) + .map_err(|error| error.to_string())?; + menu.launch_at_login + .set_text(value.launch_at_login) + .map_err(|error| error.to_string())?; + menu.quit + .set_text(value.quit) + .map_err(|error| error.to_string())?; + } + for (label, title) in [ + ("settings", value.settings_title), + ("status", value.status_title), + ("pet-debug", value.debug_title), + ] { + if let Some(window) = app.get_webview_window(label) { + window + .set_title(&title) + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + fn toggle_window_setting(app: &tauri::AppHandle, id: &str) -> Result<(), String> { let mut value = config::load()?; capture_main_position(app, &mut value.window)?; @@ -545,11 +601,17 @@ fn probe_hook(agent: String) -> Result { fn show_aux_window(app: &tauri::AppHandle, kind: &str) -> Result<(), String> { let (label, url, title, width, height) = match kind { - "settings" => ("settings", "settings.html", "Agent Cat 设置", 920.0, 720.0), + "settings" => ( + "settings", + "settings.html", + "Agent Cat Settings", + 920.0, + 720.0, + ), "pet-debug" => ( "pet-debug", "pet-debug.html", - "Agent Cat 动画测试器", + "Agent Cat Animation Tester", 980.0, 780.0, ), @@ -633,10 +695,12 @@ fn setup_tray(app: &tauri::App, value: &AppConfig) -> Result<(), String> { .map_err(|error| error.to_string())?; app.manage(TrayMenuState { show_pet, + settings, always_on_top, mouse_passthrough, lock_position, launch_at_login, + quit, }); let mut builder = TrayIconBuilder::with_id("agent-cat-tray") .menu(&menu) @@ -736,6 +800,7 @@ pub fn run() { apply_window_settings, apply_config_preview, sync_status_window, + sync_native_i18n, save_main_position, reset_main_position, autostart_status, diff --git a/src/i18n/index.test.ts b/src/i18n/index.test.ts new file mode 100644 index 0000000..c702ca0 --- /dev/null +++ b/src/i18n/index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import messages from "./messages.json"; +import { getLanguage, resolveLanguage, setLanguage, t } from "."; + +describe("i18n", () => { + it("contains complete English and Chinese translations", () => { + for (const entry of Object.values(messages)) { + expect(entry.en.trim()).not.toBe(""); + expect(entry.cn.trim()).not.toBe(""); + } + }); + + it("resolves system Chinese variants and defaults other languages to English", () => { + expect(resolveLanguage("system", "zh-CN")).toBe("cn"); + expect(resolveLanguage("system", "zh-TW")).toBe("cn"); + expect(resolveLanguage("system", "en-US")).toBe("en"); + expect(resolveLanguage("cn", "en-US")).toBe("cn"); + }); + + it("translates and interpolates named parameters", () => { + setLanguage("cn"); + expect(getLanguage()).toBe("cn"); + expect(t("{agent} Connection", { agent: "Codex" })).toBe("Codex 连接"); + setLanguage("en"); + expect(t("{agent} Connection", { agent: "Codex" })).toBe("Codex Connection"); + }); +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..1264969 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,87 @@ +import messages from "./messages.json"; + +export type Language = "en" | "cn"; +export type LanguagePreference = "system" | Language; +export type MessageKey = keyof typeof messages; +export type MessageParameters = Record; + +let activeLanguage: Language = "cn"; + +export function resolveLanguage(preference: LanguagePreference, systemLanguage?: string): Language { + if (preference !== "system") return preference; + const detected = systemLanguage ?? (typeof navigator === "undefined" ? "en" : navigator.language); + return detected.toLowerCase().startsWith("zh") ? "cn" : "en"; +} + +export function setLanguage(preference: LanguagePreference, systemLanguage?: string): Language { + activeLanguage = resolveLanguage(preference, systemLanguage); + if (typeof document !== "undefined") document.documentElement.lang = activeLanguage === "cn" ? "zh-CN" : "en"; + return activeLanguage; +} + +export function getLanguage(): Language { + return activeLanguage; +} + +export function localeTag(): string { + return activeLanguage === "cn" ? "zh-CN" : "en"; +} + +export function t(key: MessageKey, parameters: MessageParameters = {}): string { + const entry = messages[key]; + const template = entry?.[activeLanguage] ?? entry?.en ?? key; + return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, name: string) => + Object.prototype.hasOwnProperty.call(parameters, name) ? String(parameters[name]) : match, + ); +} + +function setDirectText(element: HTMLElement, value: string): void { + const textNode = [...element.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()); + if (textNode) { + const leading = textNode.textContent?.match(/^\s*/)?.[0] ?? ""; + const trailing = textNode.textContent?.match(/\s*$/)?.[0] ?? ""; + textNode.textContent = `${leading}${value}${trailing}`; + return; + } + element.prepend(document.createTextNode(value)); +} + +export function translateDocument(root: ParentNode = document): void { + for (const element of root.querySelectorAll("[data-i18n]")) { + setDirectText(element, t(element.dataset.i18n as MessageKey)); + } + for (const [attribute, dataAttribute] of [["title", "i18nTitle"], ["aria-label", "i18nAriaLabel"], ["placeholder", "i18nPlaceholder"]] as const) { + for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) { + const key = element.dataset[dataAttribute] as MessageKey | undefined; + if (key) element.setAttribute(attribute, t(key)); + } + } +} + +export type NativeMessages = { + settingsTitle: string; + statusTitle: string; + debugTitle: string; + showPet: string; + settings: string; + alwaysOnTop: string; + mousePassthrough: string; + lockPosition: string; + launchAtLogin: string; + quit: string; +}; + +export function nativeMessages(): NativeMessages { + return { + settingsTitle: t("Agent Cat Settings"), + statusTitle: t("Agent Cat Live Status"), + debugTitle: t("Agent Cat Animation Tester"), + showPet: t("Show Pet"), + settings: t("Settings…"), + alwaysOnTop: t("Always on top"), + mousePassthrough: t("Mouse passthrough"), + lockPosition: t("Lock position"), + launchAtLogin: t("Launch at login"), + quit: t("Quit Agent Cat"), + }; +} diff --git a/src/i18n/messages.json b/src/i18n/messages.json new file mode 100644 index 0000000..c1a731d --- /dev/null +++ b/src/i18n/messages.json @@ -0,0 +1,231 @@ +{ + "Agent Cat Settings": { "en": "Agent Cat Settings", "cn": "Agent Cat 设置" }, + "Agent Cat Live Status": { "en": "Agent Cat Live Status", "cn": "Agent Cat 实时状态" }, + "Agent session status": { "en": "Agent session status", "cn": "Agent 会话状态" }, + "Agent Cat Animation Tester": { "en": "Agent Cat Animation Tester", "cn": "Agent Cat 动画测试器" }, + "Settings": { "en": "Settings", "cn": "设置" }, + "Settings…": { "en": "Settings…", "cn": "设置…" }, + "Settings categories": { "en": "Settings categories", "cn": "设置分类" }, + "General": { "en": "General", "cn": "通用" }, + "Pets, display and behavior": { "en": "Pets, display and behavior", "cn": "宠物、显示与行为" }, + "Agents": { "en": "Agents", "cn": "智能体" }, + "Connections and live status": { "en": "Connections and live status", "cn": "连接与实时状态" }, + "About": { "en": "About", "cn": "关于" }, + "Version and software updates": { "en": "Version and software updates", "cn": "版本与软件更新" }, + "Update available": { "en": "Update available", "cn": "有可用更新" }, + "Choose a pet and adjust how it behaves on your desktop.": { "en": "Choose a pet and adjust how it behaves on your desktop.", "cn": "选择宠物并调整它在桌面上的表现。" }, + "Choose an agent and manage its connection, live status, and task summaries.": { "en": "Choose an agent and manage its connection, live status, and task summaries.", "cn": "选择智能体并管理连接、实时状态和任务摘要。" }, + "View the Agent Cat version and software updates.": { "en": "View the Agent Cat version and software updates.", "cn": "查看 Agent Cat 版本和软件更新。" }, + "Pet": { "en": "Pet", "cn": "宠物" }, + "Scanning…": { "en": "Scanning…", "cn": "正在扫描…" }, + "Animation Tester": { "en": "Animation Tester", "cn": "动画测试器" }, + "Refresh Pets": { "en": "Refresh Pets", "cn": "刷新宠物" }, + "Pet directory path": { "en": "Pet directory path", "cn": "宠物目录路径" }, + "Add Directory": { "en": "Add Directory", "cn": "添加目录" }, + "Open Pet Directory": { "en": "Open Pet Directory", "cn": "打开宠物目录" }, + "Display": { "en": "Display", "cn": "显示" }, + "Language": { "en": "Language", "cn": "语言" }, + "Follow system": { "en": "Follow system", "cn": "跟随系统" }, + "Pet size": { "en": "Pet size", "cn": "宠物大小" }, + "Pet opacity": { "en": "Pet opacity", "cn": "宠物透明度" }, + "Bubble size": { "en": "Bubble size", "cn": "气泡大小" }, + "Bubble opacity": { "en": "Bubble opacity", "cn": "气泡透明度" }, + "Always on top": { "en": "Always on top", "cn": "始终置顶" }, + "Mouse passthrough": { "en": "Mouse passthrough", "cn": "鼠标穿透" }, + "Lock position": { "en": "Lock position", "cn": "锁定位置" }, + "Launch at login": { "en": "Launch at login", "cn": "登录时启动" }, + "Restore Default Position": { "en": "Restore Default Position", "cn": "恢复默认位置" }, + "Behavior": { "en": "Behavior", "cn": "行为" }, + "Look at pointer while idle": { "en": "Look at pointer while idle", "cn": "空闲时看向鼠标" }, + "Wave on click": { "en": "Wave on click", "cn": "单击挥手" }, + "Jump on double-click": { "en": "Jump on double-click", "cn": "双击跳跃" }, + "Response radius": { "en": "Response radius", "cn": "响应半径" }, + "Center dead zone": { "en": "Center dead zone", "cn": "中心死区" }, + "Supported agents": { "en": "Supported agents", "cn": "已支持的智能体" }, + "Choose an agent": { "en": "Choose an agent", "cn": "选择智能体" }, + "{agent} Connection": { "en": "{agent} Connection", "cn": "{agent} 连接" }, + "Codex Connection": { "en": "Codex Connection", "cn": "Codex 连接" }, + "Claude Code Connection": { "en": "Claude Code Connection", "cn": "Claude Code 连接" }, + "Checking the Codex connection…": { "en": "Checking the Codex connection…", "cn": "正在检查 Codex 连接…" }, + "Checking the Claude Code connection…": { "en": "Checking the Claude Code connection…", "cn": "正在检查 Claude Code 连接…" }, + "Status integration": { "en": "Status integration", "cn": "状态联动" }, + "Checking the {agent} connection…": { "en": "Checking the {agent} connection…", "cn": "正在检查 {agent} 连接…" }, + "Agent Cat is checking the Hook configuration and local status receiver.": { "en": "Agent Cat is checking the Hook configuration and local status receiver.", "cn": "Agent Cat 正在检查 Hook 配置和本地状态接收器。" }, + "Checking": { "en": "Checking", "cn": "检查中" }, + "Hook configuration": { "en": "Hook configuration", "cn": "Hook 配置" }, + "Status receiver": { "en": "Status receiver", "cn": "状态接收器" }, + "Last event": { "en": "Last event", "cn": "最后事件" }, + "None": { "en": "None", "cn": "暂无" }, + "Connect and Test": { "en": "Connect and Test", "cn": "一键连接并测试" }, + "Install / Repair": { "en": "Install / Repair", "cn": "安装 / 修复" }, + "Uninstall": { "en": "Uninstall", "cn": "卸载" }, + "Test Again": { "en": "Test Again", "cn": "重新测试" }, + "Display options": { "en": "Display options", "cn": "显示选项" }, + "Show live status next to the pet": { "en": "Show live status next to the pet", "cn": "在宠物旁显示实时状态" }, + "Show task summaries": { "en": "Show task summaries", "cn": "显示任务摘要" }, + "Task summaries use only the first non-empty line of the user prompt, up to 80 characters. They are held in memory for live display and are never written to Agent Cat configuration or history. Tool arguments, file contents, and terminal output are never read or displayed.": { "en": "Task summaries use only the first non-empty line of the user prompt, up to 80 characters. They are held in memory for live display and are never written to Agent Cat configuration or history. Tool arguments, file contents, and terminal output are never read or displayed.", "cn": "任务摘要只截取用户提示的首个有效行,最多 80 个字符;仅在内存中用于实时显示,不写入 Agent Cat 配置或历史记录。工具参数、文件内容和终端输出不会被读取或显示。" }, + "Make AI agent activity visible and delightful.": { "en": "Make AI agent activity visible and delightful.", "cn": "让 AI Agent 的工作状态变得直观而有趣。" }, + "Current version": { "en": "Current version", "cn": "当前版本" }, + "Loading…": { "en": "Loading…", "cn": "读取中…" }, + "Software Updates": { "en": "Software Updates", "cn": "软件更新" }, + "Check GitHub Releases for publicly available updates.": { "en": "Check GitHub Releases for publicly available updates.", "cn": "从 GitHub Releases 检查公开发布的新版本。" }, + "Updates have not been checked": { "en": "Updates have not been checked", "cn": "尚未检查更新" }, + "New versions are downloaded in the background and wait for installation only after signature verification succeeds.": { "en": "New versions are downloaded in the background and wait for installation only after signature verification succeeds.", "cn": "检查到新版本后会在后台下载,并在签名验证通过后等待安装。" }, + "Check for Updates": { "en": "Check for Updates", "cn": "检查更新" }, + "Install and Restart": { "en": "Install and Restart", "cn": "安装并重启" }, + "Project": { "en": "Project", "cn": "项目" }, + "Agent Cat is an open-source desktop companion. Update checks never upload configuration, prompts, file contents, or usage history.": { "en": "Agent Cat is an open-source desktop companion. Update checks never upload configuration, prompts, file contents, or usage history.", "cn": "Agent Cat 是一个开源桌面伴侣。更新检查不会上传配置、提示词、文件内容或使用记录。" }, + "Supported platforms": { "en": "Supported platforms", "cn": "支持平台" }, + "Current integrations": { "en": "Current integrations", "cn": "当前集成" }, + "Remove": { "en": "Remove", "cn": "移除" }, + "Saved": { "en": "Saved", "cn": "已保存" }, + "{pets} available pets · {invalid} invalid resources": { "en": "{pets} available pets · {invalid} invalid resources", "cn": "{pets} 只可用宠物 · {invalid} 个无效资源" }, + "Unknown version": { "en": "Unknown version", "cn": "未知版本" }, + "Codex.app not found": { "en": "Codex.app not found", "cn": "未发现 Codex.app" }, + "No valid pets were found. Agent Cat will continue using the original CSS fallback cat.": { "en": "No valid pets were found. Agent Cat will continue using the original CSS fallback cat.", "cn": "没有找到有效宠物。Agent Cat 会继续使用原创 CSS fallback cat。" }, + "View invalid pets": { "en": "View invalid pets", "cn": "查看无效宠物" }, + "Claude Code has disabled all Hooks globally": { "en": "Claude Code has disabled all Hooks globally", "cn": "Claude Code 已全局禁用所有 Hooks" }, + "Enable or pause {agent} status integration": { "en": "Enable or pause {agent} status integration", "cn": "启用或暂停 {agent} 状态联动" }, + "Connect {agent} first": { "en": "Connect {agent} first", "cn": "请先连接 {agent}" }, + "{installed}/{expected}, installation required": { "en": "{installed}/{expected}, installation required", "cn": "{installed}/{expected},需要安装" }, + "Installed {installed}/{expected}": { "en": "Installed {installed}/{expected}", "cn": "已安装 {installed}/{expected}" }, + "Running": { "en": "Running", "cn": "运行中" }, + "Not running": { "en": "Not running", "cn": "未运行" }, + "Status event": { "en": "Status event", "cn": "状态事件" }, + "No events received since launch": { "en": "No events received since launch", "cn": "本次启动后尚未收到事件" }, + "No events received": { "en": "No events received", "cn": "尚未收到" }, + "One more step to connect {agent}": { "en": "One more step to connect {agent}", "cn": "还差一步即可连接 {agent}" }, + "Install the required Hooks and complete an end-to-end test through the local status receiver.": { "en": "Install the required Hooks and complete an end-to-end test through the local status receiver.", "cn": "一键安装所需 Hook,并通过本地状态接收器完成端到端测试。" }, + "Not connected": { "en": "Not connected", "cn": "未连接" }, + "Disable disableAllHooks in Claude Code settings, then return here and test the connection again.": { "en": "Disable disableAllHooks in Claude Code settings, then return here and test the connection again.", "cn": "请先在 Claude Code 设置中关闭 disableAllHooks,再回来重新测试连接。" }, + "Globally disabled": { "en": "Globally disabled", "cn": "全局禁用" }, + "{agent} status integration is paused": { "en": "{agent} status integration is paused", "cn": "{agent} 状态联动已暂停" }, + "The Hook configuration is preserved. Turn the Status integration switch above back on to resume.": { "en": "The Hook configuration is preserved. Turn the Status integration switch above back on to resume.", "cn": "Hook 配置会保留;重新打开上方“状态联动”开关即可继续。" }, + "Paused": { "en": "Paused", "cn": "已暂停" }, + "Status receiver is not running": { "en": "Status receiver is not running", "cn": "状态接收器未运行" }, + "The Hook configuration is complete, but the local receiver is unavailable. Restart Agent Cat and test again.": { "en": "The Hook configuration is complete, but the local receiver is unavailable. Restart Agent Cat and test again.", "cn": "Hook 配置完整,但本地接收器不可用;请重启 Agent Cat 后重新测试。" }, + "Restart required": { "en": "Restart required", "cn": "需重启" }, + "{agent} status integration is working": { "en": "{agent} status integration is working", "cn": "{agent} 状态联动正常" }, + "Connected": { "en": "Connected", "cn": "已连接" }, + "Hook installed, waiting for verification": { "en": "Hook installed, waiting for verification", "cn": "Hook 已安装,等待验证" }, + "Start a task in {agent} to verify a real event.": { "en": "Start a task in {agent} to verify a real event.", "cn": "请在 {agent} 中开始一个任务,以完成真实事件验证。" }, + "Waiting for verification": { "en": "Waiting for verification", "cn": "待验证" }, + "Unable to check the {agent} connection": { "en": "Unable to check the {agent} connection", "cn": "无法检查 {agent} 连接" }, + "Check failed": { "en": "Check failed", "cn": "检查失败" }, + "Unknown": { "en": "Unknown", "cn": "未知" }, + "Temporarily unable to check {agent} status integration": { "en": "Temporarily unable to check {agent} status integration", "cn": "暂时无法检查 {agent} 状态联动" }, + "Just now": { "en": "Just now", "cn": "刚刚" }, + "{count} seconds ago": { "en": "{count} seconds ago", "cn": "{count} 秒前" }, + "{count} minutes ago": { "en": "{count} minutes ago", "cn": "{count} 分钟前" }, + "{count} hours ago": { "en": "{count} hours ago", "cn": "{count} 小时前" }, + "Session started": { "en": "Session started", "cn": "会话开始" }, + "Task received": { "en": "Task received", "cn": "收到任务" }, + "Tool use started": { "en": "Tool started", "cn": "开始使用工具" }, + "Tool completed": { "en": "Tool completed", "cn": "工具执行完成" }, + "Tool failed": { "en": "Tool failed", "cn": "工具执行失败" }, + "Collaboration started": { "en": "Collaboration started", "cn": "协作开始" }, + "Collaboration completed": { "en": "Collaboration completed", "cn": "协作完成" }, + "Compacting context": { "en": "Compacting context", "cn": "整理上下文" }, + "Task resumed": { "en": "Task resumed", "cn": "继续任务" }, + "Waiting for confirmation": { "en": "Waiting for confirmation", "cn": "等待确认" }, + "Task completed": { "en": "Task completed", "cn": "任务完成" }, + "Task ended with an error": { "en": "Task ended with an error", "cn": "任务异常结束" }, + "Session ended": { "en": "Session ended", "cn": "会话退出" }, + "Task interrupted": { "en": "Task interrupted", "cn": "任务中断" }, + "Parse failed": { "en": "Parse failed", "cn": "解析失败" }, + "Show Pet": { "en": "Show Pet", "cn": "显示宠物" }, + "Quit Agent Cat": { "en": "Quit Agent Cat", "cn": "退出 Agent Cat" }, + "Codex built-in": { "en": "Codex built-in", "cn": "Codex 内置" }, + "Codex custom pets": { "en": "Codex custom pets", "cn": "Codex 自定义宠物" }, + "Other directories": { "en": "Other directories", "cn": "其他目录" }, + "New version v{version} found": { "en": "New version v{version} found", "cn": "发现新版本 v{version}" }, + "The update package will be verified after download and then wait for installation.": { "en": "The update package will be verified after download and then wait for installation.", "cn": "点击下载后将验证更新包,并等待安装。" }, + "Download Update": { "en": "Download Update", "cn": "下载更新" }, + "Checking…": { "en": "Checking…", "cn": "正在检查…" }, + "Checking for updates": { "en": "Checking for updates", "cn": "正在检查更新" }, + "Securely retrieving update information…": { "en": "Securely retrieving update information…", "cn": "正在安全地读取更新信息…" }, + "You are up to date": { "en": "You are up to date", "cn": "已是最新版本" }, + "The current version is v{version}. No updates are available.": { "en": "The current version is v{version}. No updates are available.", "cn": "当前版本 v{version},暂无可用更新。" }, + "Downloading v{version}": { "en": "Downloading v{version}", "cn": "正在下载 v{version}" }, + "Preparing download…": { "en": "Preparing download…", "cn": "正在准备下载…" }, + "Downloaded {downloaded}": { "en": "Downloaded {downloaded}", "cn": "已下载 {downloaded}" }, + "Downloaded {percent}% · {downloaded} / {total}": { "en": "Downloaded {percent}% · {downloaded} / {total}", "cn": "已下载 {percent}% · {downloaded} / {total}" }, + "v{version} is ready": { "en": "v{version} is ready", "cn": "v{version} 已准备好" }, + "The update package was downloaded and passed signature verification. It is safe to install.": { "en": "The update package was downloaded and passed signature verification. It is safe to install.", "cn": "更新包已下载并通过签名验证,可以安全安装。" }, + "Unable to check for updates": { "en": "Unable to check for updates", "cn": "暂时无法检查更新" }, + "{error}. Check your network and try again.": { "en": "{error}. Check your network and try again.", "cn": "{error}。请检查网络后重试。" }, + "Check Again": { "en": "Check Again", "cn": "再次检查" }, + "Installing…": { "en": "Installing…", "cn": "正在安装…" }, + "Installing v{version}": { "en": "Installing v{version}", "cn": "正在安装 v{version}" }, + "Agent Cat will restart automatically after installation.": { "en": "Agent Cat will restart automatically after installation.", "cn": "安装完成后 Agent Cat 会自动重启。" }, + "Update installed": { "en": "Update installed", "cn": "更新已安装" }, + "Update installation failed": { "en": "Update installation failed", "cn": "更新安装失败" }, + "Automatic restart failed. Reopen Agent Cat manually.": { "en": "Automatic restart failed. Reopen Agent Cat manually.", "cn": "自动重启失败,请手动重新打开 Agent Cat。" }, + "{error}. The download is still available, so you can retry installation.": { "en": "{error}. The download is still available, so you can retry installation.", "cn": "{error}。下载内容仍然保留,可以重试安装。" }, + "Restart Manually": { "en": "Restart Manually", "cn": "请手动重启" }, + "Retry Installation": { "en": "Retry Installation", "cn": "重试安装" }, + "Launch at login enabled": { "en": "Launch at login enabled", "cn": "已启用登录时启动" }, + "Launch at login disabled": { "en": "Launch at login disabled", "cn": "已关闭登录时启动" }, + "Unable to open the pet directory: {error}": { "en": "Unable to open the pet directory: {error}", "cn": "无法打开宠物目录:{error}" }, + "Position restored": { "en": "Position restored", "cn": "位置已恢复" }, + "Connecting…": { "en": "Connecting…", "cn": "正在连接…" }, + "Claude Code has disabled all Hooks globally. Disable disableAllHooks first.": { "en": "Claude Code has disabled all Hooks globally. Disable disableAllHooks first.", "cn": "Claude Code 已全局禁用所有 Hooks,请先关闭 disableAllHooks" }, + "Hook installed and local test passed. Waiting for a real {agent} event to verify.": { "en": "Hook installed and local test passed. Waiting for a real {agent} event to verify.", "cn": "Hook 已安装,本地测试通过;等待真实 {agent} 事件验证" }, + "The Claude Code Hook was written, but disableAllHooks currently prevents it from running.": { "en": "The Claude Code Hook was written, but disableAllHooks currently prevents it from running.", "cn": "Claude Code Hook 已写入,但 disableAllHooks 当前会阻止它运行" }, + "The {agent} Hook is installed. Run the connection test again.": { "en": "The {agent} Hook is installed. Run the connection test again.", "cn": "{agent} Hook 已安装;建议再运行一次连接测试" }, + "The Agent Cat Hook for {agent} was uninstalled": { "en": "The Agent Cat Hook for {agent} was uninstalled", "cn": "{agent} 的 Agent Cat Hook 已卸载" }, + "The local {agent} Hook test passed. Verification status is unchanged.": { "en": "The local {agent} Hook test passed. Verification status is unchanged.", "cn": "{agent} 本地 Hook 测试通过;验证状态保持不变" }, + "Scale": { "en": "Scale", "cn": "缩放" }, + "Background color": { "en": "Background color", "cn": "背景色" }, + "Standard animations": { "en": "Standard animations", "cn": "标准动画" }, + "Look directions (v2)": { "en": "Look directions (v2)", "cn": "观察方向(v2)" }, + "Mode: {mode}\nRow: {row}\nColumn: {column}\nFrame: {frame}": { "en": "Mode: {mode}\nRow: {row}\nColumn: {column}\nFrame: {frame}", "cn": "模式: {mode}\n行: {row}\n列: {column}\n帧: {frame}" }, + "Angle: {angle}°": { "en": "Angle: {angle}°", "cn": "角度: {angle}°" }, + "No pets are available for testing": { "en": "No pets are available for testing", "cn": "没有可测试的宠物" }, + "Dimensions are correct": { "en": "Dimensions are correct", "cn": "尺寸正确" }, + "Unused cells are fully transparent": { "en": "Unused cells are fully transparent", "cn": "未使用格完全透明" }, + "Unused cells contain {count} non-transparent pixels": { "en": "Unused cells contain {count} non-transparent pixels", "cn": "未使用格含 {count} 个非透明像素" }, + "{count} cells": { "en": "{count} cells", "cn": "{count} 格" }, + "The previously selected pet is unavailable. A temporary fallback is active.": { "en": "The previously selected pet is unavailable. A temporary fallback is active.", "cn": "上次选择的宠物不可用,已临时回退" }, + "Hide the current task status": { "en": "Hide the current task status", "cn": "隐藏当前任务状态" }, + "Hide the current {agent} task status": { "en": "Hide the current {agent} task status", "cn": "隐藏 {agent} 当前任务状态" }, + "{count} agent sessions. Click to {action}.": { "en": "{count} agent sessions. Click to {action}.", "cn": "{count} 个 Agent 会话,点击{action}" }, + "collapse": { "en": "collapse", "cn": "收起" }, + "expand": { "en": "expand", "cn": "展开" }, + "Tool {tool} failed. Adjusting the approach.": { "en": "Tool {tool} failed. Adjusting the approach.", "cn": "工具 {tool} 执行失败,正在调整方案" }, + "Tool failed. Adjusting the approach.": { "en": "Tool failed. Adjusting the approach.", "cn": "工具执行失败,正在调整方案" }, + "Bash command completed. Analyzing the result.": { "en": "Bash command completed. Analyzing the result.", "cn": "Bash 命令执行完成,正在分析结果" }, + "Bash command started": { "en": "Bash command started", "cn": "Bash 命令已开始执行" }, + "Code changes completed. Checking the result.": { "en": "Code changes completed. Checking the result.", "cn": "代码修改已完成,正在检查结果" }, + "Modifying code with apply_patch": { "en": "Modifying code with apply_patch", "cn": "正在通过 apply_patch 修改代码" }, + "Task plan updated. Continuing the task.": { "en": "Task plan updated. Continuing the task.", "cn": "任务计划已更新,正在继续任务" }, + "Updating the task plan": { "en": "Updating the task plan", "cn": "正在更新任务计划" }, + "Collaborative task completed. Integrating the result.": { "en": "Collaborative task completed. Integrating the result.", "cn": "协作任务已结束,正在整合结果" }, + "Starting a sub-agent to collaborate on the task": { "en": "Starting a sub-agent to collaborate on the task", "cn": "正在启动子 Agent 协作处理任务" }, + "Image loaded. Analyzing its contents.": { "en": "Image loaded. Analyzing its contents.", "cn": "图片读取完成,正在分析内容" }, + "Loading image contents": { "en": "Loading image contents", "cn": "正在读取图片内容" }, + "External tool {tool} completed. Processing the result.": { "en": "External tool {tool} completed. Processing the result.", "cn": "外部工具 {tool} 已完成,正在处理结果" }, + "Calling external tool {tool}": { "en": "Calling external tool {tool}", "cn": "正在调用外部工具 {tool}" }, + "Tool {tool} completed. Processing the result.": { "en": "Tool {tool} completed. Processing the result.", "cn": "工具 {tool} 已完成,正在处理结果" }, + "Calling tool {tool}": { "en": "Calling tool {tool}", "cn": "正在调用工具 {tool}" }, + "Tool completed. Processing the result.": { "en": "Tool completed. Processing the result.", "cn": "工具执行完成,正在处理结果" }, + "Tool started": { "en": "Tool started", "cn": "工具已开始执行" }, + "Waiting for confirmation for over 2 minutes. Check {agent}.": { "en": "Waiting for confirmation for over 2 minutes. Check {agent}.", "cn": "等待确认超过 2 分钟,请检查 {agent} 状态" }, + "No tool updates for 2 minutes. It may still be running or the connection may have failed.": { "en": "No tool updates for 2 minutes. It may still be running or the connection may have failed.", "cn": "工具 2 分钟无更新,可能仍在执行或连接异常" }, + "No updates for 2 minutes. The task may be stuck or the connection may have failed.": { "en": "No updates for 2 minutes. The task may be stuck or the connection may have failed.", "cn": "已 2 分钟无更新,任务可能卡住或连接异常" }, + "{agent} session started. Waiting for a task.": { "en": "{agent} session started. Waiting for a task.", "cn": "{agent} 会话已启动,正在等待任务" }, + "New task received. Analyzing requirements.": { "en": "New task received. Analyzing requirements.", "cn": "已收到新任务,正在分析需求" }, + "Sub-agent started. Collaborating on the task.": { "en": "Sub-agent started. Collaborating on the task.", "cn": "子 Agent 已启动,正在协作处理任务" }, + "Sub-agent completed. Integrating the result.": { "en": "Sub-agent completed. Integrating the result.", "cn": "子 Agent 已结束,正在整合协作结果" }, + "Context compaction started. Organizing the session.": { "en": "Context compaction started. Organizing the session.", "cn": "上下文压缩已开始,正在整理会话" }, + "Manual context compaction completed": { "en": "Manual context compaction completed", "cn": "手动上下文压缩已完成" }, + "Automatic context compaction completed. Continuing the task.": { "en": "Automatic context compaction completed. Continuing the task.", "cn": "自动上下文压缩已完成,正在继续任务" }, + "{agent} requests confirmation. Return to {agent} to respond.": { "en": "{agent} requests confirmation. Return to {agent} to respond.", "cn": "{agent} 请求操作确认,请返回 {agent} 处理" }, + "{agent} completed the current task": { "en": "{agent} completed the current task", "cn": "{agent} 已完成当前任务" }, + "The current {agent} task ended because of a service error": { "en": "The current {agent} task ended because of a service error", "cn": "{agent} 当前任务因服务错误而结束" }, + "{agent} session ended": { "en": "{agent} session ended", "cn": "{agent} 会话已退出" }, + "The current task was interrupted": { "en": "The current task was interrupted", "cn": "当前任务已被中断" }, + "Agent Cat could not parse the latest {agent} status": { "en": "Agent Cat could not parse the latest {agent} status", "cn": "Agent Cat 无法解析最新的 {agent} 状态" }, + "{agent} status update failed": { "en": "{agent} status update failed", "cn": "{agent} 状态更新失败" }, + "{agent} Task": { "en": "{agent} Task", "cn": "{agent} 任务" } +} diff --git a/src/live-status.ts b/src/live-status.ts index 34a9598..94a1453 100644 --- a/src/live-status.ts +++ b/src/live-status.ts @@ -1,6 +1,7 @@ import type { AgentEvent, AgentLiveStatus, AgentStatusPhase } from "./types"; import { agentEventKey, TerminalEventLedger } from "./terminal-event-ledger"; import { agentDisplayName, agentSessionKey } from "./agents"; +import { t } from "./i18n"; const ACTIVE_TIMEOUT_MS = 120_000; const STALLED_RETENTION_MS = 10 * 60_000; @@ -32,29 +33,29 @@ export function sanitizeStatusText(value: string | undefined, maxCharacters = 80 function toolDetail(event: string, toolName?: string): string { const completed = event === "PostToolUse"; if (event === "PostToolUseFailure") return toolName - ? `工具 ${toolName} 执行失败,正在调整方案` - : "工具执行失败,正在调整方案"; + ? t("Tool {tool} failed. Adjusting the approach.", { tool: toolName }) + : t("Tool failed. Adjusting the approach."); switch (toolName) { - case "Bash": return completed ? "Bash 命令执行完成,正在分析结果" : "Bash 命令已开始执行"; - case "apply_patch": return completed ? "代码修改已完成,正在检查结果" : "正在通过 apply_patch 修改代码"; - case "update_plan": return completed ? "任务计划已更新,正在继续任务" : "正在更新任务计划"; + case "Bash": return completed ? t("Bash command completed. Analyzing the result.") : t("Bash command started"); + case "apply_patch": return completed ? t("Code changes completed. Checking the result.") : t("Modifying code with apply_patch"); + case "update_plan": return completed ? t("Task plan updated. Continuing the task.") : t("Updating the task plan"); case "spawn_agent": - case "Agent": return completed ? "协作任务已结束,正在整合结果" : "正在启动子 Agent 协作处理任务"; - case "view_image": return completed ? "图片读取完成,正在分析内容" : "正在读取图片内容"; + case "Agent": return completed ? t("Collaborative task completed. Integrating the result.") : t("Starting a sub-agent to collaborate on the task"); + case "view_image": return completed ? t("Image loaded. Analyzing its contents.") : t("Loading image contents"); default: if (toolName?.startsWith("mcp__")) { const [, server, ...toolParts] = toolName.split("__"); const label = server && toolParts.length > 0 ? `${server}/${toolParts.join("/")}` : toolName; return completed - ? `外部工具 ${label} 已完成,正在处理结果` - : `正在调用外部工具 ${label}`; + ? t("External tool {tool} completed. Processing the result.", { tool: label }) + : t("Calling external tool {tool}", { tool: label }); } if (toolName) { return completed - ? `工具 ${toolName} 已完成,正在处理结果` - : `正在调用工具 ${toolName}`; + ? t("Tool {tool} completed. Processing the result.", { tool: toolName }) + : t("Calling tool {tool}", { tool: toolName }); } - return completed ? "工具执行完成,正在处理结果" : "工具已开始执行"; + return completed ? t("Tool completed. Processing the result.") : t("Tool started"); } } @@ -67,9 +68,9 @@ function transient(phase: AgentStatusPhase, detail: string, afterMs: number): Ev } function stalledDetail(phase: AgentStatusPhase, agentName: string): string { - if (phase === "waiting") return `等待确认超过 2 分钟,请检查 ${agentName} 状态`; - if (phase === "tool") return "工具 2 分钟无更新,可能仍在执行或连接异常"; - return "已 2 分钟无更新,任务可能卡住或连接异常"; + if (phase === "waiting") return t("Waiting for confirmation for over 2 minutes. Check {agent}.", { agent: agentName }); + if (phase === "tool") return t("No tool updates for 2 minutes. It may still be running or the connection may have failed."); + return t("No updates for 2 minutes. The task may be stuck or the connection may have failed."); } function eventPresentation(payload: AgentEvent): EventPresentation | null { @@ -77,23 +78,23 @@ function eventPresentation(payload: AgentEvent): EventPresentation | null { switch (payload.event) { case "SessionStart": return payload.sessionSource === "compact" ? null - : transient("starting", `${agentName} 会话已启动,正在等待任务`, SESSION_START_TIMEOUT_MS); - case "UserPromptSubmit": return active("thinking", "已收到新任务,正在分析需求"); + : transient("starting", t("{agent} session started. Waiting for a task.", { agent: agentName }), SESSION_START_TIMEOUT_MS); + case "UserPromptSubmit": return active("thinking", t("New task received. Analyzing requirements.")); case "PreToolUse": return active("tool", toolDetail(payload.event, payload.toolName)); case "PostToolUse": return active("thinking", toolDetail(payload.event, payload.toolName)); case "PostToolUseFailure": return active("thinking", toolDetail(payload.event, payload.toolName)); - case "SubagentStart": return active("tool", "子 Agent 已启动,正在协作处理任务"); - case "SubagentStop": return active("thinking", "子 Agent 已结束,正在整合协作结果"); - case "PreCompact": return active("thinking", "上下文压缩已开始,正在整理会话"); + case "SubagentStart": return active("tool", t("Sub-agent started. Collaborating on the task.")); + case "SubagentStop": return active("thinking", t("Sub-agent completed. Integrating the result.")); + case "PreCompact": return active("thinking", t("Context compaction started. Organizing the session.")); case "PostCompact": return payload.compactTrigger === "manual" - ? transient("done", "手动上下文压缩已完成", COMPACT_DONE_TIMEOUT_MS) - : active("thinking", "自动上下文压缩已完成,正在继续任务"); - case "PermissionRequest": return active("waiting", `${agentName} 请求操作确认,请返回 ${agentName} 处理`); - case "Stop": return transient("done", `${agentName} 已完成当前任务`, TASK_DONE_TIMEOUT_MS); - case "StopFailure": return transient("error", `${agentName} 当前任务因服务错误而结束`, ERROR_TIMEOUT_MS); - case "SessionEnd": return transient("done", `${agentName} 会话已退出`, SESSION_END_TIMEOUT_MS); - case "TurnInterrupted": return transient("interrupted", "当前任务已被中断", INTERRUPTED_TIMEOUT_MS); - case "HookParseError": return transient("error", `Agent Cat 无法解析最新的 ${agentName} 状态`, ERROR_TIMEOUT_MS); + ? transient("done", t("Manual context compaction completed"), COMPACT_DONE_TIMEOUT_MS) + : active("thinking", t("Automatic context compaction completed. Continuing the task.")); + case "PermissionRequest": return active("waiting", t("{agent} requests confirmation. Return to {agent} to respond.", { agent: agentName })); + case "Stop": return transient("done", t("{agent} completed the current task", { agent: agentName }), TASK_DONE_TIMEOUT_MS); + case "StopFailure": return transient("error", t("The current {agent} task ended because of a service error", { agent: agentName }), ERROR_TIMEOUT_MS); + case "SessionEnd": return transient("done", t("{agent} session ended", { agent: agentName }), SESSION_END_TIMEOUT_MS); + case "TurnInterrupted": return transient("interrupted", t("The current task was interrupted"), INTERRUPTED_TIMEOUT_MS); + case "HookParseError": return transient("error", t("Agent Cat could not parse the latest {agent} status", { agent: agentName }), ERROR_TIMEOUT_MS); default: return null; } } @@ -105,6 +106,7 @@ export class LiveStatusController { private readonly terminalEvents = new TerminalEventLedger(); private readonly sessions = new Map; updateOrder: number; }>(); @@ -144,8 +146,8 @@ export class LiveStatusController { if (suppliedTitle) this.titles.set(sessionKey, suppliedTitle); const agentName = agentDisplayName(payload.agent); const title = payload.event === "HookParseError" - ? `${agentName} 状态更新失败` - : this.titles.get(sessionKey) ?? (payload.event === "SessionStart" ? agentName : `${agentName} 任务`); + ? t("{agent} status update failed", { agent: agentName }) + : this.titles.get(sessionKey) ?? (payload.event === "SessionStart" ? agentName : t("{agent} Task", { agent: agentName })); const status: AgentLiveStatus = { agent: payload.agent, @@ -165,6 +167,7 @@ export class LiveStatusController { }, presentation.timeout.afterMs); this.sessions.set(sessionKey, { status, + event: payload, timeoutTimer, updateOrder, }); @@ -184,6 +187,26 @@ export class LiveStatusController { this.emitChange(); } + refreshLanguage(): void { + for (const current of this.sessions.values()) { + const presentation = eventPresentation(current.event); + if (!presentation) continue; + const agentName = agentDisplayName(current.event.agent); + current.status = { + ...current.status, + agentName, + title: current.event.event === "HookParseError" + ? t("{agent} status update failed", { agent: agentName }) + : this.titles.get(current.status.sessionKey) + ?? (current.event.event === "SessionStart" ? agentName : t("{agent} Task", { agent: agentName })), + detail: current.status.phase === "stalled" + ? stalledDetail(presentation.phase, agentName) + : presentation.detail, + }; + } + this.emitChange(); + } + clear(sessionKey?: string): void { if (sessionKey) { const session = this.sessions.get(sessionKey); @@ -231,6 +254,7 @@ export class LiveStatusController { phase: "stalled", detail: stalledDetail(current.status.phase, current.status.agentName), }, + event: current.event, timeoutTimer, updateOrder, }); diff --git a/src/main.ts b/src/main.ts index 270d25b..615b495 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import { UPDATE_CHECK_RETRY_MS, UPDATE_STATE_EVENT, } from "./update-indicator"; +import { nativeMessages, setLanguage, t } from "./i18n"; const stage = document.querySelector("#pet-stage")!; const sprite = document.querySelector("#pet-sprite")!; @@ -114,13 +115,17 @@ async function refreshActivePet(): Promise { reactions.refresh(); configurePointer(); errorBox.textContent = config.pet && config.pet.manifestPath !== nextPet.manifestPath - ? "上次选择的宠物不可用,已临时回退" + ? t("The previously selected pet is unavailable. A temporary fallback is active.") : ""; } async function applyConfig(next: AppConfig, forcePetRefresh = false): Promise { const previous = config; config = next; + if (!previous || previous.language !== config.language) { + setLanguage(config.language); + void invoke("sync_native_i18n", { value: nativeMessages() }); + } if (previous && agentRuntimeSignature(previous) !== agentRuntimeSignature(config)) reactions.reset(); stage.style.setProperty("--pet-opacity", String(Math.min(1, Math.max(0.2, config.window.petOpacity)))); const petChanged = forcePetRefresh diff --git a/src/pet-debug.html b/src/pet-debug.html index 62cbc0e..0c4a77a 100644 --- a/src/pet-debug.html +++ b/src/pet-debug.html @@ -3,17 +3,17 @@ - Agent Cat 动画测试器 + Agent Cat 动画测试器
-

PET LAB

动画测试器

+

PET LAB

动画测试器

- +
-

标准动画

观察方向(v2)

+

标准动画

观察方向(v2)

diff --git a/src/pet-debug.ts b/src/pet-debug.ts index 1299254..e4504e5 100644 --- a/src/pet-debug.ts +++ b/src/pet-debug.ts @@ -1,7 +1,9 @@ import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { ANIMATIONS, LOOK_ANGLES, type AnimationName } from "./animation-table"; import { PetRenderer } from "./pet-renderer"; -import type { CatalogResult, PetDescriptor } from "./types"; +import type { AppConfig, CatalogResult, PetDescriptor } from "./types"; +import { nativeMessages, setLanguage, t, translateDocument } from "./i18n"; const select = document.querySelector("#debug-pet")!; const sprite = document.querySelector("#debug-sprite")!; @@ -13,10 +15,20 @@ let pets: PetDescriptor[] = []; let active: PetDescriptor; type SpriteInspection = { unusedCells: number; nonTransparentPixels: number; transparent: boolean }; -renderer.onFrame = (frame) => { info.textContent = `模式: ${frame.mode}\n行: ${frame.row}\n列: ${frame.column}\n帧: ${frame.frame}${frame.angle === undefined ? "" : `\n角度: ${String(frame.angle).padStart(3, "0")}°`}`; }; +function applyLanguage(config: AppConfig): void { + setLanguage(config.language); + translateDocument(); +} + +renderer.onFrame = (frame) => { + const frameInfo = t("Mode: {mode}\nRow: {row}\nColumn: {column}\nFrame: {frame}", frame); + info.textContent = frame.angle === undefined ? frameInfo : `${frameInfo}\n${t("Angle: {angle}°", { angle: String(frame.angle).padStart(3, "0") })}`; +}; async function initialize(): Promise { - const catalog = await invoke("scan_pets"); + const [catalog, config] = await Promise.all([invoke("scan_pets"), invoke("get_config")]); + applyLanguage(config); + void invoke("sync_native_i18n", { value: nativeMessages() }); pets = catalog.pets; select.replaceChildren(...pets.map((pet, index) => { const option = document.createElement("option"); @@ -24,7 +36,7 @@ async function initialize(): Promise { option.textContent = `${pet.displayName} · v${pet.version}`; return option; })); - if (!pets.length) { validation.textContent = "没有可测试的宠物"; return; } + if (!pets.length) { validation.textContent = t("No pets are available for testing"); return; } const initialIndex = Math.max(0, pets.findIndex((pet) => pet.source === "codex-builtin")); select.value = String(initialIndex); await loadPet(initialIndex); @@ -38,7 +50,7 @@ async function loadPet(index: number): Promise { const scale = Number((document.querySelector("#debug-scale")!).value); await renderer.setPet(active, image, scale); const inspection = await invoke("inspect_sprite", { path: active.spritesheetPath, version: active.version }); - validation.innerHTML = `${escapeHtml(active.displayName)} · ${escapeHtml(active.source)}
尺寸正确
${active.width}×${active.height} · 8×${active.version === 2 ? 11 : 9} · cell 192×208
${inspection.transparent ? "未使用格完全透明" : `未使用格含 ${inspection.nonTransparentPixels} 个非透明像素`} · ${inspection.unusedCells} 格
${escapeHtml(active.spritesheetPath)}`; + validation.innerHTML = `${escapeHtml(active.displayName)} · ${escapeHtml(active.source)}
${t("Dimensions are correct")}
${active.width}×${active.height} · 8×${active.version === 2 ? 11 : 9} · cell 192×208
${inspection.transparent ? t("Unused cells are fully transparent") : t("Unused cells contain {count} non-transparent pixels", { count: inspection.nonTransparentPixels })} · ${t("{count} cells", { count: inspection.unusedCells })}
${escapeHtml(active.spritesheetPath)}`; document.querySelector("#look-buttons")!.classList.toggle("disabled", active.version !== 2); } @@ -66,4 +78,5 @@ function buildButtons(): void { select.addEventListener("change", () => void loadPet(Number(select.value))); document.querySelector("#debug-scale")!.addEventListener("input", (event) => renderer.resize(Number((event.target as HTMLInputElement).value))); document.querySelector("#debug-background")!.addEventListener("input", (event) => stage.style.background = (event.target as HTMLInputElement).value); +void listen("agent-cat-config-changed", async () => applyLanguage(await invoke("get_config"))); void initialize().catch((error) => validation.textContent = String(error)); diff --git a/src/settings.html b/src/settings.html index 071a114..ce397cf 100644 --- a/src/settings.html +++ b/src/settings.html @@ -3,111 +3,117 @@ - Agent Cat 设置 + Agent Cat 设置

PREFERENCES

通用

选择宠物并调整它在桌面上的表现。

-

宠物

正在扫描…

-
+

宠物

正在扫描…

+
-

显示

- - - - - - - - -
-

行为

- - - - - +

显示

+ + + + + + + + +
+

行为

+ + + + +
+
+ +
diff --git a/src/settings.ts b/src/settings.ts index 911aaeb..5bf1263 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,8 +11,23 @@ import { UPDATE_STATE_EVENT, type UpdateIndicatorState, } from "./update-indicator"; -import { advanceUpdateProgress, emptyUpdateProgress, formatBytes, updateProgressPercent } from "./update-progress"; +import { + advanceUpdateProgress, + emptyUpdateProgress, + formatBytes, + updateProgressPercent, + type UpdateDownloadProgress, +} from "./update-progress"; import { AGENT_EVENT_CHANNEL, agentDisplayName, type RawAgentEvent } from "./agents"; +import { + localeTag, + nativeMessages, + setLanguage, + t, + translateDocument, + type LanguagePreference, + type MessageKey, +} from "./i18n"; type HookStatus = { path: string; exists: boolean; valid: boolean; globallyDisabled: boolean; installedEvents: number; expectedEvents: number; message: string }; type HookRuntimeStatus = { receiverRunning: boolean; socketPath: string; verifiedAt: number | null; lastRealEventAt: number | null; lastRealEvent: string | null }; @@ -20,6 +35,16 @@ type PetDirectoryInfo = { defaultPath: string; examplePath: string }; type SettingsPage = "general" | "agents" | "about"; type IntegrationId = "codex" | "claude-code"; type IntegrationConfigKey = "codex" | "claudeCode"; +type UpdatePresentation = + | { phase: "idle" } + | { phase: "available"; version: string } + | { phase: "checking" } + | { phase: "current" } + | { phase: "downloading"; version: string; progress: UpdateDownloadProgress } + | { phase: "ready"; version: string } + | { phase: "check-error"; error: string } + | { phase: "installing"; version: string } + | { phase: "install-error"; error: string; installed: boolean }; const integrationIds = ["codex", "claude-code"] as const satisfies readonly IntegrationId[]; const agentSettingsStorageKey = "agent-cat-settings-agent"; @@ -53,29 +78,29 @@ const integrationDefinitions: Record = { - general: { eyebrow: "PREFERENCES", title: "通用", description: "选择宠物并调整它在桌面上的表现。" }, - agents: { eyebrow: "INTEGRATIONS", title: "智能体", description: "选择智能体并管理连接、实时状态和任务摘要。" }, - about: { eyebrow: "ABOUT", title: "关于", description: "查看 Agent Cat 版本和软件更新。" }, +const settingsPageCopy: Record = { + general: { eyebrow: "PREFERENCES", title: "General", description: "Choose a pet and adjust how it behaves on your desktop." }, + agents: { eyebrow: "INTEGRATIONS", title: "Agents", description: "Choose an agent and manage its connection, live status, and task summaries." }, + about: { eyebrow: "ABOUT", title: "About", description: "View the Agent Cat version and software updates." }, }; -const sourceLabels: Record = { "codex-builtin": "Codex 内置", "codex-custom": "Codex 自定义宠物", "user-folder": "其他目录" }; -const eventLabels: Record = { - SessionStart: "会话开始", - UserPromptSubmit: "收到任务", - PreToolUse: "开始使用工具", - PostToolUse: "工具执行完成", - PostToolUseFailure: "工具执行失败", - SubagentStart: "协作开始", - SubagentStop: "协作完成", - PreCompact: "整理上下文", - PostCompact: "继续任务", - PermissionRequest: "等待确认", - Stop: "任务完成", - StopFailure: "任务异常结束", - SessionEnd: "会话退出", - TurnInterrupted: "任务中断", - HookParseError: "解析失败", +const sourceLabels: Record = { "codex-builtin": "Codex built-in", "codex-custom": "Codex custom pets", "user-folder": "Other directories" }; +const eventLabels: Record = { + SessionStart: "Session started", + UserPromptSubmit: "Task received", + PreToolUse: "Tool use started", + PostToolUse: "Tool completed", + PostToolUseFailure: "Tool failed", + SubagentStart: "Collaboration started", + SubagentStop: "Collaboration completed", + PreCompact: "Compacting context", + PostCompact: "Task resumed", + PermissionRequest: "Waiting for confirmation", + Stop: "Task completed", + StopFailure: "Task ended with an error", + SessionEnd: "Session ended", + TurnInterrupted: "Task interrupted", + HookParseError: "Parse failed", }; const message = document.querySelector("#settings-message")!; const catalogElement = document.querySelector("#pet-catalog")!; @@ -96,6 +121,7 @@ let updateInstalling = false; let activeSettingsPage: SettingsPage = "general"; let activeAgentSettings: IntegrationId = readStoredAgentSettings(); let updateState: UpdateIndicatorState | null = null; +let updatePresentation: UpdatePresentation = { phase: "idle" }; let petDirectoryInfo: PetDirectoryInfo; const previewImageCache = new Map>(); const configEventSource = `settings-${crypto.randomUUID()}`; @@ -141,7 +167,7 @@ function renderAgentSettingsNavigation(): void { name.textContent = displayName; const status = document.createElement("small"); status.dataset.agentStatus = agent; - status.textContent = "检查中"; + status.textContent = t("Checking"); copy.append(name, status); button.append(icon, copy); button.addEventListener("click", () => showAgentSettings(agent)); @@ -178,8 +204,7 @@ async function initialize(): Promise { getVersion(), invoke("pet_directory_info"), ]); - input("extra-directory").placeholder = petDirectoryInfo.examplePath; - document.querySelector("#open-codex-pets")!.title = petDirectoryInfo.defaultPath; + applyLanguage(config.language); document.querySelector("#current-version")!.textContent = `v${currentVersion}`; updateState = readUpdateState(localStorage, currentVersion); refreshUpdateIndicator(); @@ -188,12 +213,27 @@ async function initialize(): Promise { await Promise.all([refreshCatalog(), refreshHookStatus("codex"), refreshHookStatus("claude-code"), refreshAutostart()]); } +function applyLanguage(preference: LanguagePreference): void { + setLanguage(preference); + translateDocument(); + if (currentVersion) document.querySelector("#current-version")!.textContent = `v${currentVersion}`; + renderUpdatePresentation(); + applyPetDirectoryInfo(); + void invoke("sync_native_i18n", { value: nativeMessages() }); +} + +function applyPetDirectoryInfo(): void { + if (!petDirectoryInfo) return; + input("extra-directory").placeholder = petDirectoryInfo.examplePath; + document.querySelector("#open-codex-pets")!.title = petDirectoryInfo.defaultPath; +} + function showSettingsPage(page: SettingsPage, updateHash = true): void { activeSettingsPage = page; const copy = settingsPageCopy[page]; document.querySelector("#settings-page-eyebrow")!.textContent = copy.eyebrow; - document.querySelector("#settings-page-title")!.textContent = copy.title; - document.querySelector("#settings-page-description")!.textContent = copy.description; + document.querySelector("#settings-page-title")!.textContent = t(copy.title); + document.querySelector("#settings-page-description")!.textContent = t(copy.description); for (const button of document.querySelectorAll("[data-settings-page]")) { const active = button.dataset.settingsPage === page; button.classList.toggle("active", active); @@ -237,16 +277,120 @@ function refreshUpdateIndicator(): void { document.querySelector("#about-update-dot")!.hidden = !hasAvailableUpdate(updateState); } +function renderUpdatePresentation(): void { + const card = document.querySelector("#update-status-card")!; + const icon = card.querySelector(".update-status-glyph")!; + const title = document.querySelector("#update-status-title")!; + const detail = document.querySelector("#update-status-detail")!; + const progressElement = document.querySelector("#update-progress")!; + const progressBar = document.querySelector("#update-progress-bar")!; + const checkButton = document.querySelector("#check-update")!; + const installButton = document.querySelector("#install-update")!; + + card.dataset.state = updatePresentation.phase === "available" + ? "ready" + : updatePresentation.phase === "check-error" || updatePresentation.phase === "install-error" + ? "error" + : updatePresentation.phase; + checkButton.hidden = false; + checkButton.disabled = false; + installButton.hidden = true; + installButton.disabled = false; + progressElement.hidden = true; + + switch (updatePresentation.phase) { + case "idle": + icon.textContent = "\u21bb"; + title.textContent = t("Updates have not been checked"); + detail.textContent = t("New versions are downloaded in the background and wait for installation only after signature verification succeeds."); + checkButton.textContent = t("Check for Updates"); + progressBar.style.width = "0%"; + break; + case "available": + icon.textContent = "\u2193"; + title.textContent = t("New version v{version} found", { version: updatePresentation.version }); + detail.textContent = t("The update package will be verified after download and then wait for installation."); + checkButton.textContent = t("Download Update"); + break; + case "checking": + icon.textContent = "\u21bb"; + title.textContent = t("Checking for updates"); + detail.textContent = t("Securely retrieving update information…"); + checkButton.disabled = true; + checkButton.textContent = t("Checking…"); + progressBar.style.width = "0%"; + break; + case "current": + icon.textContent = "\u2713"; + title.textContent = t("You are up to date"); + detail.textContent = t("The current version is v{version}. No updates are available.", { version: currentVersion }); + checkButton.textContent = t("Check Again"); + break; + case "downloading": { + const percent = updateProgressPercent(updatePresentation.progress); + icon.textContent = "\u2193"; + title.textContent = t("Downloading v{version}", { version: updatePresentation.version }); + detail.textContent = updatePresentation.progress.downloaded === 0 + ? t("Preparing download…") + : percent === null + ? t("Downloaded {downloaded}", { downloaded: formatBytes(updatePresentation.progress.downloaded) }) + : t("Downloaded {percent}% · {downloaded} / {total}", { + percent, + downloaded: formatBytes(updatePresentation.progress.downloaded), + total: formatBytes(updatePresentation.progress.total!), + }); + progressBar.style.width = `${percent ?? 0}%`; + progressElement.hidden = false; + checkButton.hidden = true; + break; + } + case "ready": + icon.textContent = "\u2713"; + title.textContent = t("v{version} is ready", { version: updatePresentation.version }); + detail.textContent = t("The update package was downloaded and passed signature verification. It is safe to install."); + progressBar.style.width = "100%"; + progressElement.hidden = false; + checkButton.hidden = true; + installButton.hidden = false; + break; + case "check-error": + icon.textContent = "!"; + title.textContent = t("Unable to check for updates"); + detail.textContent = t("{error}. Check your network and try again.", { error: updatePresentation.error }); + checkButton.textContent = t("Check Again"); + break; + case "installing": + icon.textContent = "\u21bb"; + title.textContent = t("Installing v{version}", { version: updatePresentation.version }); + detail.textContent = t("Agent Cat will restart automatically after installation."); + progressBar.style.width = "100%"; + progressElement.hidden = false; + checkButton.hidden = true; + installButton.hidden = false; + installButton.disabled = true; + installButton.textContent = t("Installing…"); + break; + case "install-error": + icon.textContent = "!"; + title.textContent = updatePresentation.installed ? t("Update installed") : t("Update installation failed"); + detail.textContent = updatePresentation.installed + ? t("Automatic restart failed. Reopen Agent Cat manually.") + : t("{error}. The download is still available, so you can retry installation.", { error: updatePresentation.error }); + progressBar.style.width = "100%"; + progressElement.hidden = false; + checkButton.hidden = true; + installButton.hidden = false; + installButton.disabled = updatePresentation.installed; + installButton.textContent = updatePresentation.installed ? t("Restart Manually") : t("Retry Installation"); + break; + } +} + function renderKnownUpdate(): void { if (!updateState?.availableVersion) return; if (!pendingUpdate && !updateChecking) { - const card = document.querySelector("#update-status-card")!; - card.dataset.state = "ready"; - card.querySelector(".update-status-glyph")!.textContent = "\u2193"; - document.querySelector("#update-status-title")!.textContent = `发现新版本 v${updateState.availableVersion}`; - document.querySelector("#update-status-detail")!.textContent = "点击下载后将验证更新包,并等待安装。"; - const button = document.querySelector("#check-update")!; - button.textContent = "下载更新"; + updatePresentation = { phase: "available", version: updateState.availableVersion }; + renderUpdatePresentation(); } } @@ -257,76 +401,40 @@ async function publishUpdateState(availableVersion: string | null): Promise { - const button = document.querySelector("#check-update")!; - const installButton = document.querySelector("#install-update")!; - const card = document.querySelector("#update-status-card")!; - const icon = card.querySelector(".update-status-glyph")!; - const title = document.querySelector("#update-status-title")!; - const detail = document.querySelector("#update-status-detail")!; - const progressElement = document.querySelector("#update-progress")!; - const progressBar = document.querySelector("#update-progress-bar")!; await closePendingUpdate(); updateChecking = true; - button.disabled = true; - button.textContent = "正在检查…"; - button.hidden = false; - installButton.hidden = true; - progressElement.hidden = true; - progressBar.style.width = "0%"; - card.dataset.state = "checking"; - icon.textContent = "\u21bb"; - title.textContent = "正在检查更新"; - detail.textContent = "正在安全地读取更新信息…"; + updatePresentation = { phase: "checking" }; + renderUpdatePresentation(); let update: Update | null = null; try { update = await check(); if (!update) { await publishUpdateState(null); - card.dataset.state = "current"; - icon.textContent = "\u2713"; - title.textContent = "已是最新版本"; - detail.textContent = `当前版本 v${currentVersion},暂无可用更新。`; + updatePresentation = { phase: "current" }; + renderUpdatePresentation(); return; } await publishUpdateState(update.version); - card.dataset.state = "downloading"; - icon.textContent = "\u2193"; - title.textContent = `正在下载 v${update.version}`; - detail.textContent = "正在准备下载…"; - button.hidden = true; - progressElement.hidden = false; let progress = { ...emptyUpdateProgress }; + updatePresentation = { phase: "downloading", version: update.version, progress }; + renderUpdatePresentation(); await update.download((event) => { progress = advanceUpdateProgress(progress, event); - const percent = updateProgressPercent(progress); - progressBar.style.width = `${percent ?? 0}%`; - detail.textContent = percent === null - ? `已下载 ${formatBytes(progress.downloaded)}` - : `已下载 ${percent}% · ${formatBytes(progress.downloaded)} / ${formatBytes(progress.total!)}`; + updatePresentation = { phase: "downloading", version: update!.version, progress }; + renderUpdatePresentation(); }); pendingUpdate = update; - card.dataset.state = "ready"; - icon.textContent = "\u2713"; - title.textContent = `v${update.version} 已准备好`; - detail.textContent = "更新包已下载并通过签名验证,可以安全安装。"; - progressBar.style.width = "100%"; - button.hidden = true; - installButton.hidden = false; + updatePresentation = { phase: "ready", version: update.version }; + renderUpdatePresentation(); } catch (error) { if (update && update !== pendingUpdate) await update.close().catch(() => undefined); - card.dataset.state = "error"; - icon.textContent = "!"; - title.textContent = "暂时无法检查更新"; - detail.textContent = `${String(error)}。请检查网络后重试。`; - progressElement.hidden = true; - button.hidden = false; + updatePresentation = { phase: "check-error", error: String(error) }; + renderUpdatePresentation(); } finally { updateChecking = false; - button.disabled = false; - button.textContent = "再次检查"; } } @@ -339,18 +447,9 @@ async function closePendingUpdate(): Promise { async function installPendingUpdate(): Promise { const update = pendingUpdate; if (!update) return; - const button = document.querySelector("#install-update")!; - const card = document.querySelector("#update-status-card")!; - const icon = card.querySelector(".update-status-glyph")!; - const title = document.querySelector("#update-status-title")!; - const detail = document.querySelector("#update-status-detail")!; updateInstalling = true; - button.disabled = true; - button.textContent = "正在安装…"; - card.dataset.state = "installing"; - icon.textContent = "\u21bb"; - title.textContent = `正在安装 v${update.version}`; - detail.textContent = "安装完成后 Agent Cat 会自动重启。"; + updatePresentation = { phase: "installing", version: update.version }; + renderUpdatePresentation(); let installed = false; try { await update.install(); @@ -359,20 +458,15 @@ async function installPendingUpdate(): Promise { await publishUpdateState(null).catch(() => undefined); await relaunch(); } catch (error) { - card.dataset.state = "error"; - icon.textContent = "!"; - title.textContent = installed ? "更新已安装" : "更新安装失败"; - detail.textContent = installed - ? "自动重启失败,请手动重新打开 Agent Cat。" - : `${String(error)}。下载内容仍然保留,可以重试安装。`; - button.disabled = installed; - button.textContent = installed ? "请手动重启" : "重试安装"; + updatePresentation = { phase: "install-error", error: String(error), installed }; + renderUpdatePresentation(); } finally { updateInstalling = false; } } function bindConfig(): void { + input("language").value = config.language; const scale = input("scale"); scale.value = String(config.window.scale); input("scale-value").value = `${Math.round(config.window.scale * 100)}%`; @@ -413,7 +507,7 @@ function renderExtraDirectories(): void { const remove = document.createElement("button"); remove.type = "button"; remove.className = "secondary compact"; - remove.textContent = "移除"; + remove.textContent = t("Remove"); remove.addEventListener("click", async () => { config.petSources.extraDirectories = config.petSources.extraDirectories.filter((value) => value !== directory); await persist(); @@ -435,7 +529,7 @@ function persist(announce = true): Promise { await invoke("save_config", { value: snapshot }); await invoke("apply_config_preview", { value: snapshot }); await emit("agent-cat-config-changed", { source: configEventSource }); - if (announce) showMessage("已保存"); + if (announce) showMessage(t("Saved")); }); persistQueue = task; return task; @@ -464,22 +558,22 @@ function previewConfig(): void { async function refreshCatalog(): Promise { catalog = await invoke("scan_pets"); - summary.textContent = `${catalog.pets.length} 只可用宠物 · ${catalog.diagnostics.length} 个无效资源${catalog.codexBundles.length ? ` · Codex ${catalog.codexBundles[0].version ?? "未知版本"}` : " · 未发现 Codex.app"}`; + summary.textContent = `${t("{pets} available pets · {invalid} invalid resources", { pets: catalog.pets.length, invalid: catalog.diagnostics.length })}${catalog.codexBundles.length ? ` · Codex ${catalog.codexBundles[0].version ?? t("Unknown version")}` : ` · ${t("Codex.app not found")}`}`; catalogElement.replaceChildren(); for (const source of ["codex-builtin", "codex-custom", "user-folder"] as PetSource[]) { const pets = catalog.pets.filter((pet) => pet.source === source); if (!pets.length) continue; const group = document.createElement("div"); group.className = "pet-group"; - group.innerHTML = `

${sourceLabels[source]}

`; + group.innerHTML = `

${t(sourceLabels[source])}

`; const grid = group.querySelector(".pet-grid")!; grid.append(...await Promise.all(pets.map(petCard))); catalogElement.append(group); } - if (!catalog.pets.length) catalogElement.innerHTML = `
没有找到有效宠物。Agent Cat 会继续使用原创 CSS fallback cat。
`; + if (!catalog.pets.length) catalogElement.innerHTML = `
${t("No valid pets were found. Agent Cat will continue using the original CSS fallback cat.")}
`; if (catalog.diagnostics.length) { const details = document.createElement("details"); - details.innerHTML = `查看无效宠物
    ${catalog.diagnostics.map((item) => `
  • ${escapeHtml(item.path)}
    ${escapeHtml(item.message)}
  • `).join("")}
`; + details.innerHTML = `${t("View invalid pets")}
    ${catalog.diagnostics.map((item) => `
  • ${escapeHtml(item.path)}
    ${escapeHtml(item.message)}
  • `).join("")}
`; catalogElement.append(details); } } @@ -541,66 +635,66 @@ async function refreshHookStatus(agent: IntegrationId): Promise { const installed = status.installedEvents === status.expectedEvents; linkToggle.disabled = !installed || status.globallyDisabled; linkControl.title = status.globallyDisabled - ? "Claude Code 已全局禁用所有 Hooks" - : installed ? `启用或暂停 ${displayName} 状态联动` : `请先连接 ${displayName}`; + ? t("Claude Code has disabled all Hooks globally") + : installed ? t("Enable or pause {agent} status integration", { agent: displayName }) : t("Connect {agent} first", { agent: displayName }); hookElement.textContent = !installed - ? `${status.installedEvents}/${status.expectedEvents},需要安装` - : status.globallyDisabled ? "Claude Code 已全局禁用所有 Hooks" : `已安装 ${status.installedEvents}/${status.expectedEvents}`; + ? t("{installed}/{expected}, installation required", { installed: status.installedEvents, expected: status.expectedEvents }) + : status.globallyDisabled ? t("Claude Code has disabled all Hooks globally") : t("Installed {installed}/{expected}", { installed: status.installedEvents, expected: status.expectedEvents }); hookElement.title = `${status.message} · ${status.path}`; hookElement.className = installed && !status.globallyDisabled ? "status-ok" : "status-error"; - receiverElement.textContent = runtime.receiverRunning ? "运行中" : "未运行"; + receiverElement.textContent = runtime.receiverRunning ? t("Running") : t("Not running"); receiverElement.title = runtime.socketPath; receiverElement.className = runtime.receiverRunning ? "status-ok" : "status-error"; eventElement.textContent = runtime.lastRealEventAt - ? `${eventLabels[runtime.lastRealEvent ?? ""] ?? runtime.lastRealEvent ?? "状态事件"} · ${relativeTime(runtime.lastRealEventAt)}` + ? `${eventLabels[runtime.lastRealEvent ?? ""] ? t(eventLabels[runtime.lastRealEvent ?? ""]) : runtime.lastRealEvent ?? t("Status event")} · ${relativeTime(runtime.lastRealEventAt)}` : runtime.verifiedAt - ? "本次启动尚未收到" - : "尚未收到"; + ? t("No events received since launch") + : t("No events received"); if (!installed) { card.dataset.state = "setup"; - title.textContent = `还差一步即可连接 ${displayName}`; - detail.textContent = "一键安装所需 Hook,并通过本地状态接收器完成端到端测试。"; - badge.textContent = "未连接"; + title.textContent = t("One more step to connect {agent}", { agent: displayName }); + detail.textContent = t("Install the required Hooks and complete an end-to-end test through the local status receiver."); + badge.textContent = t("Not connected"); } else if (status.globallyDisabled) { card.dataset.state = "error"; - title.textContent = "Claude Code 已全局禁用所有 Hooks"; - detail.textContent = "请先在 Claude Code 设置中关闭 disableAllHooks,再回来重新测试连接。"; - badge.textContent = "全局禁用"; + title.textContent = t("Claude Code has disabled all Hooks globally"); + detail.textContent = t("Disable disableAllHooks in Claude Code settings, then return here and test the connection again."); + badge.textContent = t("Globally disabled"); } else if (!currentConfig.hooksEnabled) { card.dataset.state = "paused"; - title.textContent = `${displayName} 状态联动已暂停`; - detail.textContent = "Hook 配置会保留;重新打开上方“状态联动”开关即可继续。"; - badge.textContent = "已暂停"; + title.textContent = t("{agent} status integration is paused", { agent: displayName }); + detail.textContent = t("The Hook configuration is preserved. Turn the Status integration switch above back on to resume."); + badge.textContent = t("Paused"); } else if (!runtime.receiverRunning) { card.dataset.state = "error"; - title.textContent = "状态接收器未运行"; - detail.textContent = "Hook 配置完整,但本地接收器不可用;请重启 Agent Cat 后重新测试。"; - badge.textContent = "需重启"; + title.textContent = t("Status receiver is not running"); + detail.textContent = t("The Hook configuration is complete, but the local receiver is unavailable. Restart Agent Cat and test again."); + badge.textContent = t("Restart required"); } else if (runtime.verifiedAt) { card.dataset.state = "connected"; - title.textContent = `${displayName} 状态联动正常`; + title.textContent = t("{agent} status integration is working", { agent: displayName }); detail.textContent = ""; detail.hidden = true; - badge.textContent = "已连接"; + badge.textContent = t("Connected"); } else { card.dataset.state = "pending"; - title.textContent = "Hook 已安装,等待验证"; - detail.textContent = `请在 ${displayName} 中开始一个任务,以完成真实事件验证。`; - badge.textContent = "待验证"; + title.textContent = t("Hook installed, waiting for verification"); + detail.textContent = t("Start a task in {agent} to verify a real event.", { agent: displayName }); + badge.textContent = t("Waiting for verification"); } } catch (error) { if (request !== hookRefreshRequests[agent]) return; card.dataset.state = "error"; - title.textContent = `无法检查 ${displayName} 连接`; + title.textContent = t("Unable to check the {agent} connection", { agent: displayName }); detail.textContent = String(error); - badge.textContent = "检查失败"; - hookElement.textContent = "检查失败"; + badge.textContent = t("Check failed"); + hookElement.textContent = t("Check failed"); hookElement.className = "status-error"; - receiverElement.textContent = "未知"; - eventElement.textContent = "未知"; + receiverElement.textContent = t("Unknown"); + eventElement.textContent = t("Unknown"); linkToggle.disabled = true; - linkControl.title = `暂时无法检查 ${displayName} 状态联动`; + linkControl.title = t("Temporarily unable to check {agent} status integration", { agent: displayName }); } finally { if (request === hookRefreshRequests[agent]) syncAgentNavigationState(agent); } @@ -608,11 +702,11 @@ async function refreshHookStatus(agent: IntegrationId): Promise { function relativeTime(timestamp: number): string { const elapsed = Math.max(0, Date.now() - timestamp); - if (elapsed < 10_000) return "刚刚"; - if (elapsed < 60_000) return `${Math.floor(elapsed / 1_000)} 秒前`; - if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`; - if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`; - return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(timestamp); + if (elapsed < 10_000) return t("Just now"); + if (elapsed < 60_000) return t("{count} seconds ago", { count: Math.floor(elapsed / 1_000) }); + if (elapsed < 3_600_000) return t("{count} minutes ago", { count: Math.floor(elapsed / 60_000) }); + if (elapsed < 86_400_000) return t("{count} hours ago", { count: Math.floor(elapsed / 3_600_000) }); + return new Intl.DateTimeFormat(localeTag(), { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(timestamp); } async function refreshAutostart(): Promise { @@ -680,6 +774,15 @@ for (const [id, apply] of [ await persist(); }); +input("language").addEventListener("change", async (event) => { + config.language = (event.target as HTMLSelectElement).value as LanguagePreference; + applyLanguage(config.language); + showSettingsPage(activeSettingsPage, false); + renderExtraDirectories(); + await Promise.all([refreshCatalog(), refreshHookStatus("codex"), refreshHookStatus("claude-code")]); + await persist(); +}); + for (const agent of ["codex", "claude-code"] as const) { const definition = integrationDefinitions[agent]; input(definition.linkId).addEventListener("change", async (event) => { @@ -704,7 +807,7 @@ input("launch-at-login").addEventListener("change", async (event) => { try { await invoke("set_autostart", { enabled }); await refreshAutostart(); - showMessage(enabled ? "已启用登录时启动" : "已关闭登录时启动"); + showMessage(enabled ? t("Launch at login enabled") : t("Launch at login disabled")); } catch (error) { await refreshAutostart(); showMessage(String(error), true); @@ -728,10 +831,10 @@ document.querySelector("#open-codex-pets")!.addEventListener("click", async () = try { await invoke("reveal_pet_directory"); } catch (error) { - showMessage(`无法打开宠物目录:${String(error)}`, true); + showMessage(t("Unable to open the pet directory: {error}", { error: String(error) }), true); } }); -document.querySelector("#reset-position")!.addEventListener("click", async () => { config = await invoke("reset_main_position"); bindConfig(); showMessage("位置已恢复"); }); +document.querySelector("#reset-position")!.addEventListener("click", async () => { config = await invoke("reset_main_position"); bindConfig(); showMessage(t("Position restored")); }); function bindIntegrationActions(agent: IntegrationId): void { const definition = integrationDefinitions[agent]; const displayName = agentDisplayName(agent); @@ -739,12 +842,12 @@ function bindIntegrationActions(agent: IntegrationId): void { document.querySelector(`#${definition.connectId}`)!.addEventListener("click", async (event) => { const button = event.currentTarget as HTMLButtonElement; button.disabled = true; - button.textContent = "正在连接…"; + button.textContent = t("Connecting…"); try { const status = await invoke("install_hooks", { agent }); if (status.globallyDisabled) { await refreshHookStatus(agent); - showMessage("Claude Code 已全局禁用所有 Hooks,请先关闭 disableAllHooks", true); + showMessage(t("Claude Code has disabled all Hooks globally. Disable disableAllHooks first."), true); return; } const currentConfig = integrationConfig(agent); @@ -754,13 +857,13 @@ function bindIntegrationActions(agent: IntegrationId): void { await persist(); await invoke("probe_hook", { agent }); await refreshHookStatus(agent); - showMessage(`Hook 已安装,本地测试通过;等待真实 ${displayName} 事件验证`); + showMessage(t("Hook installed and local test passed. Waiting for a real {agent} event to verify.", { agent: displayName })); } catch (error) { await refreshHookStatus(agent); showMessage(String(error), true); } finally { button.disabled = false; - button.textContent = "一键连接并测试"; + button.textContent = t("Connect and Test"); } }); document.querySelector(`#${definition.installId}`)!.addEventListener("click", async () => { @@ -776,8 +879,8 @@ function bindIntegrationActions(agent: IntegrationId): void { await refreshHookStatus(agent); showMessage( installedStatus.globallyDisabled - ? "Claude Code Hook 已写入,但 disableAllHooks 当前会阻止它运行" - : `${displayName} Hook 已安装;建议再运行一次连接测试`, + ? t("The Claude Code Hook was written, but disableAllHooks currently prevents it from running.") + : t("The {agent} Hook is installed. Run the connection test again.", { agent: displayName }), installedStatus.globallyDisabled, ); } catch (error) { showMessage(String(error), true); } @@ -789,16 +892,16 @@ function bindIntegrationActions(agent: IntegrationId): void { bindConfig(); await persist(); await refreshHookStatus(agent); - showMessage(`${displayName} 的 Agent Cat Hook 已卸载`); + showMessage(t("The Agent Cat Hook for {agent} was uninstalled", { agent: displayName })); } catch (error) { showMessage(String(error), true); } }); document.querySelector(`#${definition.testId}`)!.addEventListener("click", async () => { try { const status = await invoke("hook_status", { agent }); - if (status.globallyDisabled) throw new Error("Claude Code 已全局禁用所有 Hooks,请先关闭 disableAllHooks"); + if (status.globallyDisabled) throw new Error(t("Claude Code has disabled all Hooks globally. Disable disableAllHooks first.")); await invoke("probe_hook", { agent }); await refreshHookStatus(agent); - showMessage(`${displayName} 本地 Hook 测试通过;验证状态保持不变`); + showMessage(t("The local {agent} Hook test passed. Verification status is unchanged.", { agent: displayName })); } catch (error) { await refreshHookStatus(agent); showMessage(String(error), true); @@ -819,6 +922,7 @@ window.addEventListener("hashchange", showSettingsRoute); void listen<{ source?: string }>("agent-cat-config-changed", async ({ payload }) => { if (payload?.source === configEventSource) return; config = await invoke("get_config"); + applyLanguage(config.language); bindConfig(); }); void listen("agent-cat-autostart-changed", () => void refreshAutostart()); diff --git a/src/status.html b/src/status.html index 7a5529b..fef427f 100644 --- a/src/status.html +++ b/src/status.html @@ -3,12 +3,12 @@ - Agent Cat 实时状态 + Agent Cat 实时状态
- +
`; stack.append(card); } @@ -113,14 +114,14 @@ async function render(statuses: AgentLiveStatus[]): Promise { source.hidden = !showTaskSummary; source.textContent = showTaskSummary ? status.agentName : ""; const dismiss = card.querySelector(".status-dismiss")!; - dismiss.setAttribute("aria-label", `隐藏 ${status.agentName} 当前任务状态`); - dismiss.title = `隐藏 ${status.agentName} 当前任务状态`; + dismiss.setAttribute("aria-label", t("Hide the current {agent} task status", { agent: status.agentName })); + dismiss.title = t("Hide the current {agent} task status", { agent: status.agentName }); }); shell.dataset.expanded = String(expanded); toggle.hidden = statuses.length < 2; toggle.setAttribute("aria-expanded", String(expanded)); - toggle.setAttribute("aria-label", `${statuses.length} 个 Agent 会话,点击${expanded ? "收起" : "展开"}`); + toggle.setAttribute("aria-label", t("{count} agent sessions. Click to {action}.", { count: statuses.length, action: expanded ? t("collapse") : t("expand") })); const height = contentHeight(statuses.length); shell.style.setProperty("--content-height", `${height}px`); shell.hidden = false; @@ -153,8 +154,15 @@ async function loadConfig(): Promise { function applyConfig(next: AppConfig): void { const previousSignature = config ? agentRuntimeSignature(config) : null; + const languageChanged = !config || config.language !== next.language; const nextSignature = agentRuntimeSignature(next); config = next; + if (languageChanged) { + setLanguage(config.language); + translateDocument(); + void invoke("sync_native_i18n", { value: nativeMessages() }); + controller.refreshLanguage(); + } if (previousSignature !== null && previousSignature !== nextSignature) { lastEventKey = ""; controller.reset(); diff --git a/src/styles.css b/src/styles.css index 4cabf05..0d7d08b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -214,6 +214,14 @@ h3 { margin: 16px 0 9px; font-size: 14px; color: #667779; } section { margin-top: 14px; padding: 20px; border: 1px solid #dce5e2; border-radius: 16px; background: #ffffffd9; box-shadow: 0 8px 30px #2d45420b; } .settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 24px; margin-bottom: 14px; } label { display: flex; align-items: center; gap: 9px; } +.language-settings { padding-block: 17px; } +.language-setting-row { justify-content: space-between; gap: 24px; } +.language-setting-label { color: #3d5052; font-size: 15px; } +.language-select-control { position: relative; flex: 0 0 210px; } +.language-select-control::after { position: absolute; top: 50%; right: 14px; width: 7px; height: 7px; border-right: 2px solid #71807f; border-bottom: 2px solid #71807f; content: ""; pointer-events: none; transform: translateY(-70%) rotate(45deg); } +.language-select-control select { width: 100%; padding: 9px 38px 9px 12px; border: 1px solid #cbd8d4; border-radius: 10px; outline: none; color: #3d5052; background: linear-gradient(180deg, #fff, #f7faf8); box-shadow: 0 1px 2px #2d45420b; cursor: pointer; appearance: none; transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; } +.language-select-control select:hover { border-color: #aebfba; background: #fff; } +.language-select-control select:focus-visible { border-color: #df6a41; box-shadow: 0 0 0 3px #df6a4124; } input[type="range"] { flex: 1; accent-color: #df6a41; } input[type="checkbox"] { width: 17px; height: 17px; accent-color: #df6a41; } input[type="text"], input:not([type]), .inline-form input { min-width: 0; padding: 9px 11px; border: 1px solid #cbd8d4; border-radius: 9px; outline: none; background: white; } @@ -301,4 +309,5 @@ details { margin-top: 14px; color: #89534b; font-size: 12px; } details li { marg .button-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 8px; }.button-grid button { background: #526e70; }.button-grid.disabled { opacity: .35; pointer-events: none; } @media (max-width: 700px) { .settings-grid, .debug-workbench, .integration-meta { grid-template-columns: 1fr; }.debug-shell { padding: 20px 16px 44px; }.settings-shell { grid-template-columns: 150px minmax(0, 1fr); }.settings-sidebar { padding-inline: 9px; }.settings-content { padding: 22px 18px 44px; }.settings-nav-item small { display: none; }.about-card { align-items: flex-start; flex-direction: column; }.about-version { text-align: left; } } +@media (max-width: 700px) { .language-setting-row { align-items: stretch; flex-direction: column; gap: 10px; }.language-select-control { flex-basis: auto; width: 100%; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }.status-indicator { border-top-color: #6f7779; } } diff --git a/src/types.ts b/src/types.ts index 2012627..b0aa610 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ export type CatalogResult = { export type AppConfig = { version: 1; + language: "system" | "en" | "cn"; pet: { source: PetSource; id: string; manifestPath: string } | null; petSources: { scanCodexBuiltin: boolean; scanCodexCustom: boolean; extraDirectories: string[] }; window: { x: number | null; y: number | null; scale: number; petOpacity: number; alwaysOnTop: boolean; mousePassthrough: boolean; lockPosition: boolean }; diff --git a/tsconfig.json b/tsconfig.json index 5cf82e8..e800628 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "strict": true, "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],