From 779442fe2978ae37973f6acccd120c973480df8a Mon Sep 17 00:00:00 2001 From: smryyyyy <1619500656@qq.com> Date: Thu, 28 May 2026 15:59:17 +0800 Subject: [PATCH] feat: add i18n infrastructure and Chinese (zh-CN) localization - Add lightweight translation system (src/language/) with t() function, zero external dependencies - Add en.json and zh-CN.json covering ~900 UI strings - Convert 60+ source files from hardcoded English to t() calls - Add language selector on welcome page and in Settings - Fix module-level t() timing issues with lazy initialization - Translate welcome page, sidebar, settings, dialogs, commands, toasts, shortcuts, extensions hub, file dialogs, and status bar - Add cn_README.md with full Chinese documentation - Update HTML lang attributes to zh-CN - Fix pre-commit lint errors in settings-overlay.ts - Default language is English, switchable to Chinese --- README.md | 16 + cn_README.md | 288 ++++++ package-lock.json | 8 +- package.json | 2 +- public/architecture/index.html | 2 +- public/index.html | 2 +- public/oauth-callback.html | 2 +- src/commands/builtins/addons.ts | 3 +- src/commands/builtins/clipboard.ts | 9 +- .../builtins/custom-gateway-settings.ts | 63 +- src/commands/builtins/debug.ts | 13 +- src/commands/builtins/experimental-overlay.ts | 19 +- src/commands/builtins/experimental.ts | 9 +- src/commands/builtins/export.ts | 55 +- .../builtins/extensions-hub-connections.ts | 93 +- .../extensions-hub-extension-connections.ts | 8 +- .../builtins/extensions-hub-overlay.ts | 13 +- .../builtins/extensions-hub-plugins.ts | 19 +- .../builtins/extensions-hub-skills.ts | 25 +- src/commands/builtins/files.ts | 3 +- src/commands/builtins/help.ts | 3 +- src/commands/builtins/model.ts | 7 +- .../builtins/overlay-relative-date.ts | 10 +- src/commands/builtins/recovery-overlay.ts | 63 +- src/commands/builtins/resume-overlay.ts | 41 +- src/commands/builtins/resume-target.ts | 10 +- src/commands/builtins/rules-overlay.ts | 58 +- src/commands/builtins/session.ts | 49 +- src/commands/builtins/settings-overlay.ts | 144 ++- src/commands/builtins/settings.ts | 9 +- src/commands/builtins/shortcuts-overlay.ts | 55 +- src/commands/builtins/skills.ts | 3 +- src/commands/builtins/tools.ts | 3 +- src/compat/thinking-duration.ts | 6 +- src/execution/controller.ts | 3 +- src/execution/mode.ts | 4 +- src/experiments/flags.ts | 111 +-- src/extensions/permissions.ts | 291 ++---- src/extensions/runtime-manager.ts | 3 +- src/files/backend.ts | 3 +- src/files/workspace.ts | 11 +- src/language/index.ts | 39 + src/language/locales/en.json | 911 ++++++++++++++++++ src/language/locales/zh-CN.json | 911 ++++++++++++++++++ src/taskpane.html | 2 +- src/taskpane/bootstrap.ts | 9 +- src/taskpane/init.ts | 91 +- src/taskpane/keyboard-shortcuts.ts | 3 +- .../keyboard-shortcuts/editor-actions.ts | 3 +- src/taskpane/queue-display.ts | 5 +- src/taskpane/session-title.ts | 4 +- src/taskpane/status-bar.ts | 27 +- src/taskpane/status-context.ts | 30 +- src/taskpane/status-popovers.ts | 53 +- src/taskpane/welcome-login.ts | 93 +- src/tools/get-workbook-overview.ts | 5 +- src/ui-gallery.html | 2 +- src/ui/bridge-setup-card.ts | 39 +- src/ui/confirm-dialog.ts | 14 +- src/ui/disclosure-bar.ts | 25 +- src/ui/files-dialog-actions.ts | 20 +- src/ui/files-dialog-filtering.ts | 7 +- src/ui/files-dialog-status.ts | 3 +- src/ui/files-dialog.ts | 49 +- src/ui/loading.ts | 3 +- src/ui/message-renderers.ts | 13 +- src/ui/pi-input.ts | 23 +- src/ui/pi-sidebar.ts | 83 +- src/ui/provider-login.ts | 123 ++- src/ui/proxy-banner.ts | 23 +- src/ui/render-csv-table.ts | 2 +- src/ui/text-input-dialog.ts | 14 +- src/ui/toast.ts | 1 + src/ui/tool-renderers.ts | 18 +- src/ui/web-search-setup-card.ts | 43 +- src/ui/whimsical-messages.ts | 258 ++--- src/ui/working-indicator.ts | 17 +- src/workbook/context.ts | 4 +- 78 files changed, 3365 insertions(+), 1144 deletions(-) create mode 100644 cn_README.md create mode 100644 src/language/index.ts create mode 100644 src/language/locales/en.json create mode 100644 src/language/locales/zh-CN.json diff --git a/README.md b/README.md index 8df571f8..579ac846 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,25 @@ # Pi for Excel +> **🌐 [English](README.md) | [中文](cn_README.md)** + Open-source, multi-model AI sidebar add-in for Microsoft Excel. Powered by [Pi](https://pi.dev). Pi for Excel is an AI agent that lives inside Excel. It reads your workbook, makes changes, and does research — using any model you choose. Bring your own API key or OAuth login for Anthropic, OpenAI, Google Gemini, or GitHub Copilot. +--- + +## 🌍 Language Support + +This project now supports **Simplified Chinese (zh-CN)** alongside English: + +- **Chinese UI translation**: Most interface text is translated into Chinese. Switch language in **Settings → Advanced → Language** or use the language toggle on the welcome screen. +- **Translation system**: All user-facing UI strings use a lightweight `t()` translation function with zero third-party dependencies. Translations are stored in `src/language/locales/` as JSON files. +- **Adding new languages**: Create a new `{lang}.json` file in `src/language/locales/` and import it in `src/language/index.ts`. + +See [cn_README.md](cn_README.md) for the Chinese version of this document. + +--- + ## Features **Core spreadsheet tools** — 16 built-in tools that the AI can call to interact with your workbook: diff --git a/cn_README.md b/cn_README.md new file mode 100644 index 00000000..e7aeaf8b --- /dev/null +++ b/cn_README.md @@ -0,0 +1,288 @@ +# Pi for Excel + +> **🌐 [English](README.md) | [中文](cn_README.md)** + +开源、多模型的 Excel 侧边栏 AI 加载项。由 [Pi](https://pi.dev) 驱动。 + +Pi for Excel 是一个驻留在 Excel 中的 AI 智能体。它能读取你的工作簿、执行修改、进行调研——使用你选择的任意模型。自带 API 密钥或通过 OAuth 登录 Anthropic、OpenAI、Google Gemini 或 GitHub Copilot。 + +--- + +## 功能特性 + +**核心电子表格工具** — 16 个内置工具,AI 可调用它们与你的工作簿交互: + +| 工具 | 功能 | +|------|------| +| `get_workbook_overview` | 结构蓝图——工作表、表头、命名区域、表格、图表、数据透视表 | +| `read_range` | 以紧凑(markdown)、CSV 或详细(含格式)模式读取单元格 | +| `write_cells` | 写入值/公式,带覆盖保护和自动验证 | +| `fill_formula` | 跨区域自动填充公式(相对引用自动调整) | +| `search_workbook` | 跨所有工作表查找文本、值或公式引用 | +| `modify_structure` | 插入/删除行/列、添加/重命名/删除/隐藏工作表 | +| `format_cells` | 应用格式——字体、颜色、数字格式、边框、命名样式 | +| `conditional_format` | 添加或清除条件格式规则 | +| `trace_dependencies` | 追踪公式血统(前导引用或后续依赖) | +| `explain_formula` | 用通俗语言解释公式,附带单元格引用 | +| `view_settings` | 网格线、标题、冻结窗格、选项卡颜色、工作表可见性 | +| `comments` | 读取、添加、更新、回复、解决/重新打开单元格批注 | +| `workbook_history` | 列出/恢复工作簿修改的自动备份(保存间隔间) | +| `instructions` | AI 的持久用户级和工作簿级指导 | +| `conventions` | 可配置的格式默认值(货币、负数、零、小数位) | +| `skills` | 用于特定任务工作流的捆绑 Agent 技能 | + +**多模型支持** — 使用任何支持的提供商;会话中可切换模型: +- **Anthropic**(Claude)— API 密钥或 OAuth +- **OpenAI** / **OpenAI Codex** — API 密钥 +- **Google Gemini** — API 密钥 +- **GitHub Copilot** — OAuth +- **自定义 OpenAI 兼容网关** — 在 `/settings` 中配置端点 + 模型 + API 密钥 + +**会话管理** — 每个工作簿多个会话标签页、自动保存/恢复、会话历史、使用 `/resume` 从上次中断处继续。 + +**自动上下文注入** — AI 在每次对话前自动接收工作簿蓝图、当前选择区域和最近的单元格变更。无需手动描述你正在查看的内容。 + +**工作簿恢复** — 每次修改前自动创建检查点。如果出错,可从侧边栏一键撤销。 + +**格式约定** — 一次性定义你的风格(货币符号、负号样式、小数位数),AI 自动遵循。 + +**斜杠命令** — `/model`、`/login`、`/settings`、`/rules`、`/extensions`、`/tools`、`/export`、`/compact`、`/new`、`/resume`、`/history`、`/shortcuts` 等。 + +**扩展** — 通过聊天安装侧边栏扩展(迷你应用)。AI 可直接通过 `extensions_manager` 工具生成并安装扩展代码。扩展默认在 iframe 沙盒中运行。 + +**集成** — 可选的外部工具集成: +- **网络搜索**(默认 Jina,可选 Serper/Tavily/Brave)+ `fetch_page` — 无需离开 Excel 即可查找和阅读外部来源 +- **MCP 网关** — 连接到用户配置的 MCP 服务器以访问自定义工具 + +**桥接 + 高级控制**(通过 `/experimental` 管理): +- Tmux 桥接设置——配置桥接 URL/令牌并运行健康检查 +- Python / LibreOffice 桥接设置——配置桥接 URL/令牌 +- 文件工作区写入/删除门控——跨会话共享工件存储(内置助手文档在 `assistant-docs/` 下始终可读) +- 高级扩展控制——远程 URL 选择加入、权限强制执行、沙盒回滚和 Widget API v2 + +(网络搜索和 MCP 在 `/tools` 或 `/extensions` → 连接中管理。) + +--- + +## 🌍 语言支持 + +本项目在英文基础上增加了对**简体中文(zh-CN)** 的支持: + +- **中文界面翻译**:大部分界面文本已翻译为中文。可在 **设置 → 高级 → 语言** 中切换,或在欢迎页使用语言选择按钮。 +- **翻译系统**:所有面向用户的 UI 字符串使用轻量级 `t()` 翻译函数,零第三方依赖。翻译文件存储在 `src/language/locales/` 目录下,为 JSON 格式。 +- **添加新语言**:在 `src/language/locales/` 中创建 `{lang}.json` 文件,并在 `src/language/index.ts` 中导入即可。 + +--- + +## 安装 + +1. 下载 [`manifest.prod.xml`](https://pi-for-excel.vercel.app/manifest.prod.xml) +2. 将其添加至 Excel——详见 [**安装指南**](docs/install.md)(含 macOS + Windows 逐步说明) +3. 单击功能区中的 **打开 Pi** +4. 连接提供商(API 密钥或 OAuth),或在 `/settings` 中配置自定义 OpenAI 兼容网关 +5. 开始对话——试试"我有哪些工作表?"或"总结我当前选中的内容" + +--- + +## 开发者快速入门 + +### 前置条件 + +- **Node.js ≥ 20** +- **mkcert** — 用于本地 HTTPS(Office.js 要求) + +### 设置 + +```bash +git clone https://github.com/tmustier/pi-for-excel.git +cd pi-for-excel +npm install + +# 生成本地 HTTPS 证书(Office.js 需要 HTTPS) +mkcert -install # 一次性 CA 设置 +mkcert localhost # 创建 localhost.pem + localhost-key.pem +mv localhost-key.pem key.pem +mv localhost.pem cert.pem +``` + +### 运行 + +```bash +npm run dev # Vite 开发服务器,https://localhost:3000 +``` + +然后将开发清单侧载到 Excel: + +**macOS**([Microsoft 文档](https://learn.microsoft.com/en-us/office/dev/add-ins/testing/sideload-an-office-add-in-on-mac)): +```bash +cp manifest.xml ~/Library/Containers/com.microsoft.Excel/Data/Documents/wef/ +``` +然后打开 Excel → **插入** → **我的加载项** → **Pi for Excel**。 + +**Windows**([Microsoft 文档](https://learn.microsoft.com/en-us/office/dev/add-ins/testing/sideload-office-add-ins-for-testing)): + +打开 Excel → **插入** → **我的加载项** → **上传我的加载项** → 选择 `manifest.xml`。 + +开发清单指向 `https://localhost:3000`。生产清单(`manifest.prod.xml`)指向托管的 Vercel 部署。 + +### 有用命令 + +| 命令 | 说明 | +|------|------| +| `npm run dev` | 启动 Vite 开发服务器(端口 3000,HTTPS) | +| `npm run build` | 生产构建 → `dist/` | +| `npm run check` | 代码检查 + 类型检查 + CSS 主题检查 | +| `npm run typecheck` | 仅 TypeScript 类型检查 | +| `npm run lint` | ESLint | +| `npm run test:models` | 单元测试——模型排序 | +| `npm run test:context` | 单元测试——工具、上下文、会话、扩展、集成 | +| `npm run test:security` | 安全策略测试——代理、CORS、沙盒、OAuth | +| `npm run proxy:https` | OAuth 流程的 CORS 代理(默认 `https://localhost:3003`) | +| `npm run validate` | 验证 Office 加载项清单 | + +### CORS 代理 + +某些 OAuth 令牌端点在 Office WebView 中被 CORS 阻止。如果 OAuth 登录失败: + +1. 用户设置命令:`npx pi-for-excel-proxy`(如果缺少 Node.js,可用 `curl -fsSL https://piforexcel.com/proxy | sh`) +2. 开发/源码设置命令:`npm run proxy:https`(默认 `https://localhost:3003`) +3. 在 Pi → `/settings` → **代理** → 启用并设置 URL +4. 重试登录 + +API 密钥认证通常无需代理即可使用。 + +### 本地桥接(Python / tmux) + +使用一键本地桥接助手: + +- Python / LibreOffice 桥接:`npx pi-for-excel-python-bridge`(默认 URL `https://localhost:3340`,真实模式) +- tmux 桥接:`npx pi-for-excel-tmux-bridge`(默认 URL `https://localhost:3341`,真实模式) + +在 Pi 中,这些 localhost 桥接 URL 默认使用。仅当需要非默认 URL 时才配置 `/experimental ...-bridge-url`。 + +真实模式前置条件: + +- `python3` 必须安装(用于 `python_run` / `python_transform_range`) +- LibreOffice(`soffice` 或 `libreoffice`)是 `libreoffice_convert` 所必需的 +- `tmux` 是 tmux 桥接真实模式所必需的 + +可选的辅助安装(macOS/Homebrew): + +- `npx pi-for-excel-python-bridge --install-missing` +- `npx pi-for-excel-tmux-bridge --install-missing` + +手动 macOS 安装: + +```bash +brew install tmux +brew install --cask libreoffice +``` + +要强制使用安全模拟模式: + +- `PYTHON_BRIDGE_MODE=stub npx pi-for-excel-python-bridge` +- `TMUX_BRIDGE_MODE=stub npx pi-for-excel-tmux-bridge` + +源码检出替代方案仍可通过 `npm run python:bridge:https` 和 `npm run tmux:bridge:https` 使用。 + +--- + +## 架构 + +Pi for Excel 是一个单页 Office 任务窗格加载项,使用以下技术构建: + +- **[Vite](https://vite.dev/)** — 开发服务器 + 生产打包器 +- **[Lit](https://lit.dev/)** — 侧边栏 UI 的 Web 组件 +- **[pi-agent-core](https://www.npmjs.com/package/@earendil-works/pi-agent-core)** — 代理运行时(工具循环、流式传输、状态管理) +- **[pi-ai](https://www.npmjs.com/package/@earendil-works/pi-ai)** — 多提供商 LLM 抽象(Anthropic、OpenAI、Google、GitHub Copilot) +- **[pi-web-ui](https://www.npmjs.com/package/@earendil-works/pi-web-ui)** — 共享 Web UI 组件(消息渲染、存储、设置对话框) +- **[Office.js](https://learn.microsoft.com/en-us/office/dev/add-ins/)** — Excel 工作簿 API + +### 源码布局 + +``` +src/ +├── taskpane/ # 应用初始化、会话管理、标签布局、上下文注入 +├── taskpane.html # 入口 HTML(加载 Office.js + taskpane.ts) +├── taskpane.ts # 入口脚本 +├── boot.ts # 挂载前设置(CSS、补丁) +├── tools/ # 16 个核心工具 + 功能标记工具 + 注册表 +├── prompt/ # 系统提示构建器 +├── context/ # 工作簿蓝图缓存、选择/变更追踪 +├── auth/ # OAuth 提供商、API 代理、凭据恢复 +├── models/ # 模型排序 + 版本评分 +├── ui/ # 侧边栏组件、工具渲染器、主题 CSS +│ └── theme/ # 设计令牌、组件样式(DM Sans + 青绿色调色板) +├── commands/ # 斜杠命令注册表 + 内置命令 +├── extensions/ # 扩展存储、沙盒运行时、权限 +├── integrations/ # 网络搜索 + MCP 网关集成目录 +├── skills/ # Agent 技能目录 + 运行时加载器 +├── experiments/ # 功能标记定义 + 切换逻辑 +├── workbook/ # 工作簿标识(哈希)、会话关联、协调器 +├── conventions/ # 格式默认值(货币、负数、小数位) +├── rules/ # 持久化用户/工作簿规则存储 +├── compaction/ # 自动压缩阈值 + 逻辑 +├── storage/ # IndexedDB 初始化 +├── files/ # 文件工作区(始终可读/列表;写入/删除受功能门控) +├── audit/ # 工作簿变更审计日志 +├── messages/ # 消息转换助手 +├── debug/ # 调试模式工具 +├── stubs/ # CSP/仅 Node 依赖的浏览器存根(Ajv、Bedrock、stream 等) +├── compat/ # 兼容性补丁(Lit、marked、模型选择器) +├── language/ # 翻译系统(t() 函数 + en.json + zh-CN.json) +└── utils/ # 共享助手(HTML 转义、类型守卫、错误处理) + +scripts/ # 开发助手——CORS 代理、tmux/Python 桥接、清单生成 +pkg/proxy/ # 可发布的 npm CLI 包:`pi-for-excel-proxy` +pkg/python-bridge/ # 可发布的 npm CLI 包:`pi-for-excel-python-bridge` +pkg/tmux-bridge/ # 可发布的 npm CLI 包:`pi-for-excel-tmux-bridge` +tests/ # 单元测试 + 安全测试(约 50 个测试文件) +docs/ # 当前文档(安装/部署/功能/策略)+ archive/ 历史规划 +skills/ # 捆绑的 Agent 技能定义(web-search、mcp-gateway、tmux-bridge、python-bridge) +public/assets/ # 加载项图标(16/32/80/128px) +``` + +### 关键设计模式 + +- **工具注册表为单一真实来源** — `src/tools/registry.ts` 定义所有核心工具名称和构造。UI 渲染器、输入人性化器和提示文档均由此派生。 +- **工作簿协调器** — 按工作簿序列化可变工具调用,防止多个会话标签页并发写入。 +- **自动上下文** — 在每次用户消息前注入工作簿蓝图、选择状态和最近变更,使 AI 始终知道它在查看什么。 +- **执行策略** — 每个工具被分类为 `read/none` 或 `mutate/content|structure`,以确定锁定和检查点行为。 +- **恢复检查点** — 修改操作在写入前自动快照受影响的单元格,实现一键回滚。 +- **扩展沙盒** — 不受信任的扩展(内联代码、远程 URL)默认在 iframe 沙盒中运行;内置/本地模块在主机上运行。 + +--- + +## 部署 + +生产构建是部署到 [Vercel](https://vercel.com) 的静态站点。维护者设置请参阅 [docs/deploy-vercel.md](docs/deploy-vercel.md)。 + +用户通过下载 `manifest.prod.xml` 并在 Excel 中上传来安装——清单指向托管的 Vercel URL。更新是自动的(关闭并重新打开任务窗格)。 + +--- + +## 文档 + +| 文档 | 说明 | +|------|------| +| [docs/install.md](docs/install.md) | 非技术用户安装指南 | +| [docs/deploy-vercel.md](docs/deploy-vercel.md) | 托管部署(Vercel) | +| [docs/extensions.md](docs/extensions.md) | 扩展开发指南 | +| [docs/integrations-external-tools.md](docs/integrations-external-tools.md) | 网络搜索 + MCP 集成设置 | +| [docs/security-threat-model.md](docs/security-threat-model.md) | 安全威胁模型 | +| [docs/compaction.md](docs/compaction.md) | 会话压缩(`/compact`) | +| [src/tools/DECISIONS.md](src/tools/DECISIONS.md) | 工具行为决策日志 | +| [src/ui/README.md](src/ui/README.md) | UI 架构 + Tailwind v4 说明 | + +--- + +## 致谢 + +- [Pi](https://github.com/badlogic/pi-mono) by [@badlogic](https://github.com/badlogic)(Mario Zechner)— 驱动此项目的代理框架。Pi for Excel 使用 pi-agent-core、pi-ai 和 pi-web-ui 实现代理循环、LLM 抽象和会话存储。 +- [whimsical.ts](https://github.com/mitsuhiko/agent-stuff/blob/main/pi-extensions/whimsical.ts) by [@mitsuhiko](https://github.com/mitsuhiko)(Armin Ronacher)— 轮播的"工作中…"消息改编自他的 Pi 扩展,为电子表格/财务场景重写。 + +--- + +## 许可证 + +[MIT](LICENSE) © Thomas Mustier diff --git a/package-lock.json b/package-lock.json index 242c59f2..1a27acb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "typebox": "^1.1.32", "typescript": "^5.7.0", "typescript-eslint": "^8.57.0", - "vite": "^7.3.2" + "vite": "^7.3.3" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -15167,9 +15167,9 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index daf1592b..a030b627 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "typebox": "^1.1.32", "typescript": "^5.7.0", "typescript-eslint": "^8.57.0", - "vite": "^7.3.2" + "vite": "^7.3.3" }, "license": "MIT", "repository": { diff --git a/public/architecture/index.html b/public/architecture/index.html index b0f047e4..fe1af9ad 100644 --- a/public/architecture/index.html +++ b/public/architecture/index.html @@ -1,5 +1,5 @@ - + diff --git a/public/index.html b/public/index.html index 447afd48..4bdf0dba 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + diff --git a/public/oauth-callback.html b/public/oauth-callback.html index cd8848fc..483f0c54 100644 --- a/public/oauth-callback.html +++ b/public/oauth-callback.html @@ -1,5 +1,5 @@ - + diff --git a/src/commands/builtins/addons.ts b/src/commands/builtins/addons.ts index 34754837..f1877b8d 100644 --- a/src/commands/builtins/addons.ts +++ b/src/commands/builtins/addons.ts @@ -4,6 +4,7 @@ import type { ExtensionsHubTab } from "./extensions-hub-overlay.js"; import type { SlashCommand } from "../types.js"; +import { t } from "../../language/index.js"; export interface AddonsCommandActions { openExtensionsHub: (tab?: ExtensionsHubTab) => void | Promise; @@ -13,7 +14,7 @@ export function createAddonsCommands(actions: AddonsCommandActions): SlashComman return [ { name: "extensions", - description: "Open Extensions (connections, plugins, skills)", + description: t("command.addons.desc"), source: "builtin", execute: () => { void actions.openExtensionsHub(); diff --git a/src/commands/builtins/clipboard.ts b/src/commands/builtins/clipboard.ts index c5944b38..df6234b7 100644 --- a/src/commands/builtins/clipboard.ts +++ b/src/commands/builtins/clipboard.ts @@ -8,6 +8,7 @@ import type { SlashCommand } from "../types.js"; import type { ActiveAgentProvider } from "./model.js"; import { showToast } from "../../ui/toast.js"; import { extractTextBlocks } from "../../utils/content.js"; +import { t } from "../../language/index.js"; function getLastAssistantText(messages: AgentMessage[]): string | null { for (let i = messages.length - 1; i >= 0; i--) { @@ -28,24 +29,24 @@ export function createClipboardCommands(getActiveAgent: ActiveAgentProvider): Sl return [ { name: "copy", - description: "Copy last agent message to clipboard", + description: t("command.clipboard.desc"), source: "builtin", execute: () => { const agent = resolveAgent(getActiveAgent); if (!agent) { - showToast("No active session"); + showToast(t("command.clipboard.no_session")); return; } const text = getLastAssistantText(agent.state.messages); if (text) { void navigator.clipboard.writeText(text).then(() => { - showToast("Copied to clipboard"); + showToast(t("clipboard.copied")); }); return; } - showToast("No agent message to copy"); + showToast(t("clipboard.noAgentMessage")); }, }, ]; diff --git a/src/commands/builtins/custom-gateway-settings.ts b/src/commands/builtins/custom-gateway-settings.ts index 1d2fc788..3b20e1f6 100644 --- a/src/commands/builtins/custom-gateway-settings.ts +++ b/src/commands/builtins/custom-gateway-settings.ts @@ -19,6 +19,7 @@ import { } from "../../ui/extensions-hub-components.js"; import { createOverlaySectionTitle } from "../../ui/overlay-dialog.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; interface BuildCustomGatewaySectionOptions { onProvidersChanged: () => void; @@ -62,14 +63,14 @@ function createGatewayCard(args: { const actions = document.createElement("div"); actions.className = "pi-settings-gateway-item__actions"; - const editButton = createButton("Edit", { + const editButton = createButton(t("custom-gateway.editButton"), { compact: true, onClick: () => { args.onEdit(args.gateway); }, }); - const deleteButton = createButton("Delete", { + const deleteButton = createButton(t("custom-gateway.deleteButton"), { compact: true, danger: true, onClick: () => { @@ -82,19 +83,19 @@ function createGatewayCard(args: { const endpoint = document.createElement("p"); endpoint.className = "pi-settings-gateway-item__meta"; - endpoint.textContent = `Endpoint: ${args.gateway.endpointUrl}`; + endpoint.textContent = t("custom-gateway.gatewayEndpoint", { url: args.gateway.endpointUrl }); const model = document.createElement("p"); model.className = "pi-settings-gateway-item__meta"; - model.textContent = `Model: ${args.gateway.modelId}`; + model.textContent = t("custom-gateway.gatewayModel", { id: args.gateway.modelId }); const contextWindow = document.createElement("p"); contextWindow.className = "pi-settings-gateway-item__meta"; - contextWindow.textContent = `Max context: ${formatTokenCount(args.gateway.contextWindow)}`; + contextWindow.textContent = t("custom-gateway.gatewayContextWindow", { tokens: formatTokenCount(args.gateway.contextWindow) }); const keyState = document.createElement("p"); keyState.className = "pi-settings-gateway-item__meta"; - keyState.textContent = args.gateway.apiKey.length > 0 ? "API key: configured" : "API key: none"; + keyState.textContent = args.gateway.apiKey.length > 0 ? t("custom-gateway.gatewayApiKeyConfigured") : t("custom-gateway.gatewayApiKeyNone"); card.append(topRow, endpoint, model, contextWindow, keyState); return card; @@ -107,10 +108,8 @@ export async function buildCustomGatewaySection( section.className = "pi-overlay-section pi-settings-section"; section.dataset.settingsAnchor = "custom-gateways"; - const title = createOverlaySectionTitle("Custom OpenAI-compatible gateways"); - const hint = createHint( - "Use this for company LLM gateways or local OpenAI-compatible servers.", - ); + const title = createOverlaySectionTitle(t("custom-gateway.title")); + const hint = createHint(t("custom-gateway.hint")); const content = document.createElement("div"); content.className = "pi-settings-section__content"; @@ -119,16 +118,16 @@ export async function buildCustomGatewaySection( formCard.className = "pi-overlay-surface pi-settings-gateway-form"; const nameInput = createConfigInput({ - placeholder: "Gateway name (optional)", + placeholder: t("custom-gateway.namePlaceholder"), }); const endpointInput = createConfigInput({ - placeholder: "https://your-gateway.example.com/v1", + placeholder: t("custom-gateway.endpointPlaceholder"), }); endpointInput.spellcheck = false; const modelInput = createConfigInput({ - placeholder: "model-id", + placeholder: t("custom-gateway.modelPlaceholder"), }); const contextWindowInput = createConfigInput({ @@ -140,7 +139,7 @@ export async function buildCustomGatewaySection( contextWindowInput.inputMode = "numeric"; const apiKeyInput = createConfigInput({ - placeholder: "API key (optional for local servers)", + placeholder: t("custom-gateway.apiKeyPlaceholder"), type: "password", }); @@ -151,12 +150,12 @@ export async function buildCustomGatewaySection( const formActions = document.createElement("div"); formActions.className = "pi-overlay-actions"; - const cancelButton = createButton("Cancel", { + const cancelButton = createButton(t("custom-gateway.cancelButton"), { compact: true, }); cancelButton.hidden = true; - const saveButton = createButton("Save gateway", { + const saveButton = createButton(t("custom-gateway.saveGateway"), { compact: true, primary: true, }); @@ -164,21 +163,21 @@ export async function buildCustomGatewaySection( formActions.append(cancelButton, saveButton); formCard.append( - createConfigRow("Name", nameInput), - createConfigRow("Endpoint", endpointInput), - createConfigRow("Model", modelInput), - createConfigRow("Max context tokens", contextWindowInput), + createConfigRow(t("custom-gateway.configLabelName"), nameInput), + createConfigRow(t("custom-gateway.configLabelEndpoint"), endpointInput), + createConfigRow(t("custom-gateway.configLabelModel"), modelInput), + createConfigRow(t("custom-gateway.configLabelContextWindow"), contextWindowInput), createHint( - "Used for Pi's local context budgeting and auto-compaction. Set this to your gateway model's real context window.", + t("custom-gateway.contextWindowHint"), ), - createConfigRow("API key", apiKeyInput), + createConfigRow(t("custom-gateway.configLabelApiKey"), apiKeyInput), errorText, formActions, ); const listTitle = document.createElement("p"); listTitle.className = "pi-settings-gateway-list__title"; - listTitle.textContent = "Configured gateways"; + listTitle.textContent = t("custom-gateway-settings.configured-gateways"); const listHost = document.createElement("div"); listHost.className = "pi-settings-gateway-list"; @@ -205,7 +204,7 @@ export async function buildCustomGatewaySection( contextWindowInput.value = ""; apiKeyInput.value = ""; cancelButton.hidden = true; - saveButton.textContent = "Save gateway"; + saveButton.textContent = t("custom-gateway.saveGateway"); setError(null); }; @@ -217,7 +216,7 @@ export async function buildCustomGatewaySection( contextWindowInput.value = String(gateway.contextWindow); apiKeyInput.value = gateway.apiKey; cancelButton.hidden = false; - saveButton.textContent = "Update gateway"; + saveButton.textContent = t("custom-gateway-settings.update-gateway"); setError(null); nameInput.focus(); }; @@ -230,7 +229,7 @@ export async function buildCustomGatewaySection( listHost.replaceChildren(); if (gateways.length === 0) { - listHost.appendChild(createHint("No custom gateways configured yet.")); + listHost.appendChild(createHint(t("custom-gateway.noGateways"))); return; } @@ -242,9 +241,9 @@ export async function buildCustomGatewaySection( void (async () => { try { const confirmed = await requestConfirmationDialog({ - title: "Delete custom gateway?", - message: `Delete gateway \"${targetGateway.displayName}\"? This removes its stored API key from this add-in.`, - confirmLabel: "Delete", + title: t("custom-gateway.deleteConfirmTitle"), + message: t("custom-gateway.deleteConfirmMsg", { name: targetGateway.displayName }), + confirmLabel: t("custom-gateway.deleteButton"), confirmButtonTone: "danger", restoreFocusOnClose: false, }); @@ -257,7 +256,7 @@ export async function buildCustomGatewaySection( await reloadGateways(); renderList(); options.onProvidersChanged(); - showToast(`Deleted gateway ${targetGateway.displayName}.`); + showToast(t("custom-gateway.deletedGateway", { name: targetGateway.displayName })); if (editingGatewayId === targetGateway.id) { resetForm(); @@ -302,8 +301,8 @@ export async function buildCustomGatewaySection( options.onProvidersChanged(); showToast( editingGatewayId - ? `Updated gateway ${saved.displayName}.` - : `Saved gateway ${saved.displayName}.`, + ? t("custom-gateway.updatedGateway", { name: saved.displayName }) + : t("custom-gateway.savedGateway", { name: saved.displayName }), ); resetForm(); diff --git a/src/commands/builtins/debug.ts b/src/commands/builtins/debug.ts index 7020ce12..5873a171 100644 --- a/src/commands/builtins/debug.ts +++ b/src/commands/builtins/debug.ts @@ -6,6 +6,7 @@ import type { SlashCommand } from "../types.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { isDebugEnabled, setDebugEnabled, toggleDebugEnabled } from "../../debug/debug.js"; function normalizeArg(args: string): string { @@ -16,35 +17,35 @@ export function createDebugCommands(): SlashCommand[] { return [ { name: "debug", - description: "Toggle debug UI (usage breakdown, extra diagnostics)", + description: t("command.debug.toggle"), source: "builtin", execute: (args: string) => { const a = normalizeArg(args); if (a === "" || a === "toggle") { const enabled = toggleDebugEnabled(); - showToast(`Debug ${enabled ? "enabled" : "disabled"}`); + showToast(t(enabled ? "command.debug.enabled" : "command.debug.disabled")); return; } if (a === "on" || a === "true" || a === "1") { setDebugEnabled(true); - showToast("Debug enabled"); + showToast(t("command.debug.enabled")); return; } if (a === "off" || a === "false" || a === "0") { setDebugEnabled(false); - showToast("Debug disabled"); + showToast(t("command.debug.disabled")); return; } if (a === "status") { - showToast(`Debug is ${isDebugEnabled() ? "enabled" : "disabled"}`); + showToast(t(isDebugEnabled() ? "command.debug.is_enabled" : "command.debug.is_disabled")); return; } - showToast("Usage: /debug [on|off|toggle|status]"); + showToast(t("command.debug.usage")); }, }, ]; diff --git a/src/commands/builtins/experimental-overlay.ts b/src/commands/builtins/experimental-overlay.ts index 5f91ba56..610a18a5 100644 --- a/src/commands/builtins/experimental-overlay.ts +++ b/src/commands/builtins/experimental-overlay.ts @@ -9,6 +9,7 @@ import { } from "../../experiments/flags.js"; import { createToggleRow } from "../../ui/extensions-hub-components.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; const ADVANCED_SECURITY_FEATURE_IDS = new Set([ "remote_extension_urls", @@ -40,7 +41,7 @@ function buildFeatureRow(feature: ExperimentalFeatureSnapshot): HTMLElement { const suffix = feature.wiring === "flag-only" ? " (flag saved; feature not wired yet)" : ""; - showToast(`${feature.title}: ${checked ? "enabled" : "disabled"}${suffix}`); + showToast(`${feature.title}: ${checked ? t("experimental.enabled") : t("experimental.disabled")}${suffix}`); }, }); toggleRow.root.classList.add("pi-experimental-row__toggle-row"); @@ -62,8 +63,8 @@ function buildFeatureRow(feature: ExperimentalFeatureSnapshot): HTMLElement { const readiness = document.createElement("div"); readiness.className = "pi-experimental-row__readiness"; readiness.textContent = feature.wiring === "wired" - ? "Ready now" - : "Flag only for now — this capability is planned but not wired yet."; + ? t("experimental.ready") + : t("experimental.notWired") + " — this capability is planned but not wired yet."; row.append(toggleRow.root, meta, warning, readiness); return row; @@ -111,8 +112,8 @@ export function buildExperimentalFeatureContent(): HTMLDivElement { if (experimentalFeatures.length > 0) { content.appendChild(buildFeatureSection({ - title: "Experimental capabilities", - hint: "In-progress features that may evolve quickly.", + title: t("experimental.title"), + hint: t("experimental.hint"), features: experimentalFeatures, })); } @@ -121,7 +122,7 @@ export function buildExperimentalFeatureContent(): HTMLDivElement { if (advancedSecurityFeatures.length > 0) { content.appendChild(buildFeatureSection({ - title: "Advanced / security controls", + title: t("experimental.advancedSecurity"), hint: "Power-user toggles for extension trust, permissions, and rollback behavior.", features: advancedSecurityFeatures, })); @@ -130,7 +131,7 @@ export function buildExperimentalFeatureContent(): HTMLDivElement { if (snapshots.length === 0) { const empty = document.createElement("p"); empty.className = "pi-overlay-empty"; - empty.textContent = "No experimental features are currently available."; + empty.textContent = t("experimental.noFeatures"); content.appendChild(empty); } @@ -140,9 +141,7 @@ export function buildExperimentalFeatureContent(): HTMLDivElement { export function buildExperimentalFeatureFooter(): HTMLParagraphElement { const footer = document.createElement("p"); footer.className = "pi-experimental-footer"; - footer.textContent = - "Tip: use /experimental on , /experimental off , /experimental toggle , " - + "/experimental tmux-bridge-url , /experimental tmux-bridge-token , or /experimental tmux-status."; + footer.textContent = t("experimental.tip"); return footer; } diff --git a/src/commands/builtins/experimental.ts b/src/commands/builtins/experimental.ts index 8d309077..28ea396d 100644 --- a/src/commands/builtins/experimental.ts +++ b/src/commands/builtins/experimental.ts @@ -26,6 +26,7 @@ import { PYTHON_BRIDGE_TOKEN_SETTING_KEY } from "../../tools/python-run.js"; import { TMUX_BRIDGE_TOKEN_SETTING_KEY } from "../../tools/tmux.js"; import { isRecord } from "../../utils/type-guards.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { showExperimentalDialog } from "./experimental-overlay.js"; const ENABLE_ACTIONS = new Set(["enable", "on"]); @@ -166,7 +167,7 @@ function getLegacyFeatureRedirectMessage(featureArg: string): string | null { return null; } - return "External tools (including MCP) are managed in /tools, not /experimental."; + return t("experimental.legacy_redirect"); } function usageText(): string { @@ -182,7 +183,7 @@ function usageText(): string { function featureListText(getFeatureSlugs: () => string[]): string { const slugs = getFeatureSlugs(); - return slugs.length > 0 ? slugs.join(", ") : "(none)"; + return slugs.length > 0 ? slugs.join(", ") : t("experimental.feature.none"); } async function getSettingsStore() { @@ -612,7 +613,7 @@ export function createExperimentalCommands( return [ { name: "experimental", - description: "Manage experimental features", + description: t("command.experimental.manage"), source: "builtin", execute: async (args: string) => { try { @@ -667,7 +668,7 @@ export function createExperimentalCommands( if (TMUX_STATUS_ACTIONS.has(action)) { if (tokens.length > 1) { - resolved.showToast("Usage: /experimental tmux-status"); + resolved.showToast(t("experimental.usage.tmux_status")); return; } diff --git a/src/commands/builtins/export.ts b/src/commands/builtins/export.ts index 145d2ded..82b79ae0 100644 --- a/src/commands/builtins/export.ts +++ b/src/commands/builtins/export.ts @@ -8,6 +8,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { SlashCommand } from "../types.js"; import type { ActiveAgentProvider } from "./model.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { createCompactionSummaryMessage } from "../../messages/compaction.js"; import { createArchivedMessagesMessage, @@ -112,14 +113,15 @@ async function exportWorkbookAuditLog(rawArgs: string): Promise { if (destination === "clipboard") { await navigator.clipboard.writeText(json); - showToast( - `Audit log copied (${entries.length} entries, ${(json.length / 1024).toFixed(0)}KB)`, - ); + showToast(t("export.toast.audit_copied", { + count: String(entries.length), + size: (json.length / 1024).toFixed(0), + })); return; } triggerJsonDownload(`pi-audit-log-${new Date().toISOString().slice(0, 10)}.json`, json); - showToast(`Downloaded audit log (${entries.length} entries)`); + showToast(t("export.toast.audit_downloaded", { count: String(entries.length) })); } // ============================================================================= @@ -406,7 +408,7 @@ export function createExportCommands(getActiveAgent: ActiveAgentProvider): Slash return [ { name: "export", - description: "Export JSON (session transcript or audit log)", + description: t("command.export.json"), source: "builtin", execute: async (args: string) => { const parts = args.trim().split(/\s+/u).filter((part) => part.length > 0); @@ -416,20 +418,20 @@ export function createExportCommands(getActiveAgent: ActiveAgentProvider): Slash try { await exportWorkbookAuditLog(parts.slice(1).join(" ")); } catch (error: unknown) { - showToast(`Audit export failed: ${getErrorMessage(error)}`); + showToast(t("export.toast.audit_export_failed", { error: getErrorMessage(error) })); } return; } const agent = getActiveAgent(); if (!agent) { - showToast("No active session"); + showToast(t("export.toast.no_session")); return; } const msgs = agent.state.messages; if (msgs.length === 0) { - showToast("No messages to export"); + showToast(t("export.toast.no_messages")); return; } @@ -468,17 +470,18 @@ export function createExportCommands(getActiveAgent: ActiveAgentProvider): Slash if (destination === "clipboard") { try { await navigator.clipboard.writeText(json); - showToast( - `Transcript copied (${msgs.length} messages, ${(json.length / 1024).toFixed(0)}KB)`, - ); + showToast(t("export.toast.transcript_copied", { + count: String(msgs.length), + size: (json.length / 1024).toFixed(0), + })); } catch (error: unknown) { - showToast(`Copy failed: ${getErrorMessage(error)}`); + showToast(t("export.toast.copy_failed", { error: getErrorMessage(error) })); } return; } triggerJsonDownload(`pi-session-${new Date().toISOString().slice(0, 10)}.json`, json); - showToast(`Downloaded transcript (${msgs.length} messages)`); + showToast(t("export.toast.transcript_downloaded", { count: String(msgs.length) })); }, }, ]; @@ -488,12 +491,12 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas return [ { name: "compact", - description: "Summarize older messages to free context", + description: t("command.export.summarize"), source: "builtin", execute: async (args: string) => { const agent = getActiveAgent(); if (!agent) { - showToast("No active session"); + showToast(t("export.toast.no_session")); return; } @@ -504,16 +507,16 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas } = splitArchivedMessages(allMessages); if (messagesWithoutArchived.length < 4) { - showToast("Too few messages to compact"); + showToast(t("export.toast.compact.few_messages")); return; } - showToast("Compacting to free up context", 60000); + showToast(t("export.toast.compact.compacting"), 60000); const now = Date.now(); const model = agent.state.model; if (!isApiModel(model)) { - showToast("No model configured for compaction"); + showToast(t("export.toast.compact.no_model")); return; } @@ -524,7 +527,7 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas // and can crash in browser WebViews due to env key fallbacks using `process`. const apiKey = agent.getApiKey ? await agent.getApiKey(model.provider) : undefined; if (!apiKey) { - showToast(`No API key available for ${model.provider}. Use /login or /settings.`); + showToast(t("export.toast.compact.no_api_key", { provider: model.provider })); return; } @@ -560,9 +563,11 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas const memoryCues = collectCompactionMemoryCues(messagesToSummarize); if (memoryCues.cueCount > 0 && !memoryNudgeShown) { - const cueLabel = memoryCues.cueCount === 1 ? "cue" : "cues"; showToast( - `Compaction reminder: found ${memoryCues.cueCount} memory ${cueLabel} in older messages. Save durable facts to notes/ (rules via instructions) if needed.`, + t("export.toast.compact.memory_nudge", { + count: String(memoryCues.cueCount), + cue: "", + }), 12000, ); memoryNudgeShown = true; @@ -644,7 +649,7 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas if (!isPromptTooLongError(e)) throw e; // Retry once with more aggressive truncation + keeping a larger recent tail. - showToast("Compaction input too large — retrying with stronger truncation", 60000); + showToast(t("export.toast.compact.retrying"), 60000); const keepMoreRecent = Math.min(contextWindow, keepRecentTokens * 2); out = await runOnce(aggressiveLimits, keepMoreRecent); @@ -667,14 +672,14 @@ export function createCompactCommands(getActiveAgent: ActiveAgentProvider): Slas const iface = document.querySelector("pi-sidebar"); iface?.requestUpdate(); - showToast(`Summarized ${out.summarizedCount} messages`); + showToast(t("export.toast.compact.summarized", { count: String(out.summarizedCount) })); } catch (e: unknown) { const msg = getErrorMessage(e); if (msg === "Nothing to compact") { - showToast("Nothing to compact"); + showToast(t("export.toast.compact.nothing")); return; } - showToast(`Compact failed: ${msg}`); + showToast(t("export.toast.compact.failed", { msg })); } }, }, diff --git a/src/commands/builtins/extensions-hub-connections.ts b/src/commands/builtins/extensions-hub-connections.ts index bc605317..3f3fe5c4 100644 --- a/src/commands/builtins/extensions-hub-connections.ts +++ b/src/commands/builtins/extensions-hub-connections.ts @@ -62,6 +62,7 @@ import { createToggle, } from "../../ui/extensions-hub-components.js"; import { lucide, Search, Terminal, Zap } from "../../ui/lucide-icons.js"; +import { t } from "../../language/index.js"; import type { ExtensionsHubDependencies } from "./extensions-hub-overlay.js"; import { renderExtensionConnectionsSection } from "./extensions-hub-extension-connections.js"; @@ -97,13 +98,13 @@ function describeWebSearchAvailability(args: { } if (sessionEnabled) { - return hasWorkbook ? "Session only" : "Session"; + return hasWorkbook ? t("ext-hub-connections.scopeSessionOnly") : "Session"; } - return hasWorkbook ? "Off in all scopes" : "Off"; + return hasWorkbook ? t("ext-hub-connections.scopeOff") : "Off"; } -const BRIDGE_SETUP_HINT = "Open Terminal · paste · press Enter · type y and Enter if prompted · leave open"; +const BRIDGE_SETUP_HINT = t("ext-hub-connections.setupHint"); function selectElementText(element: HTMLElement): void { const selection = window.getSelection(); @@ -130,7 +131,7 @@ function createBridgeSetupCommand(command: string): HTMLDivElement { copyButton.type = "button"; copyButton.className = "pi-hub-bridge-setup__copy"; copyButton.textContent = "📋"; - copyButton.title = "Copy command"; + copyButton.title = t("bridge-setup.copyCommandTitle"); copyButton.addEventListener("click", () => { if (!navigator.clipboard?.writeText) { selectElementText(code); @@ -218,8 +219,8 @@ export async function renderConnectionsTab(args: { surface.className = "pi-overlay-surface"; const masterToggle = createToggleRow({ - label: "External tools", - sublabel: "Allow Pi to search the web and call external services", + label: t("extensions-hub-connections.externalTools"), + sublabel: t("ext-hub-connections.allowExternal"), checked: externalEnabled, onChange: (checked) => { void runMutation( @@ -233,13 +234,13 @@ export async function renderConnectionsTab(args: { container.appendChild(surface); // ── Web search section ──────────────────────── - container.appendChild(createSectionHeader({ label: "Web search" })); + container.appendChild(createSectionHeader({ label: t("extensions-hub-connections.webSearch") })); const webBadgeText = !webSearchEnabled - ? "Off" + ? t("extensions-hub-connections.webSearchOff") : apiKey - ? "Connected" - : (isApiKeyRequired(selectedProvider) ? "No API key" : "Ready"); + ? t("extensions-hub-connections.webSearchConnected") + : (isApiKeyRequired(selectedProvider) ? t("extensions-hub-connections.noApiKey") : t("extensions-hub-connections.ready")); const webBadgeTone = !webSearchEnabled ? "muted" : (apiKey || !isApiKeyRequired(selectedProvider) ? "ok" : "warn"); @@ -272,7 +273,7 @@ export async function renderConnectionsTab(args: { ); }); - webCard.body.appendChild(createConfigRow("Provider", providerSelect)); + webCard.body.appendChild(createConfigRow(t("extensions-hub-connections.providerLabel"), providerSelect)); // API key row const apiKeyInput = createConfigInput({ @@ -286,12 +287,12 @@ export async function renderConnectionsTab(args: { const apiKeyLabel = document.createElement("span"); apiKeyLabel.className = "pi-item-card__config-label"; - apiKeyLabel.textContent = "API key"; + apiKeyLabel.textContent = t("extensions-hub-connections.apiKeyLabel"); const apiKeyControls = document.createElement("div"); apiKeyControls.className = "pi-hub-inline-row"; - const validateBtn = createButton("Validate", { + const validateBtn = createButton(t("extensions-hub-connections.validateButton"), { compact: true, onClick: () => { if (isBusy()) return; @@ -300,7 +301,7 @@ export async function renderConnectionsTab(args: { try { const config = await loadWebSearchProviderConfig(settings); const testKey = key.length > 0 ? key : (getApiKeyForProvider(config) ?? ""); - if (!testKey) { showToast("No API key to validate."); return; } + if (!testKey) { showToast(t("extensions-hub-connections.toast.noApiKeyToValidate")); return; } const proxyBaseUrl = await getEnabledProxyBaseUrl(settings); const result = await validateWebSearchApiKey({ provider: selectedProvider, apiKey: testKey, proxyBaseUrl }); showToast(result.ok ? `✓ ${result.message}` : `✗ ${result.message}`); @@ -311,12 +312,12 @@ export async function renderConnectionsTab(args: { }, }); - const saveKeyBtn = createButton("Save", { + const saveKeyBtn = createButton(t("extensions-hub-connections.saveButton"), { primary: true, compact: true, onClick: () => { const key = apiKeyInput.value.trim(); - if (!key) { showToast("Enter an API key first."); return; } + if (!key) { showToast(t("extensions-hub-connections.toast.enterApiKey")); return; } const formatWarning = checkApiKeyFormat(selectedProvider, key); void runMutation( () => saveWebSearchApiKey(settings, selectedProvider, key), @@ -328,7 +329,7 @@ export async function renderConnectionsTab(args: { }, }); - const clearKeyBtn = createButton("Clear", { + const clearKeyBtn = createButton(t("extensions-hub-connections.clearButton"), { compact: true, onClick: () => { void runMutation( @@ -359,17 +360,17 @@ export async function renderConnectionsTab(args: { const scopeSummary = document.createElement("summary"); scopeSummary.className = "pi-hub-advanced-summary"; - scopeSummary.textContent = "Scope controls"; + scopeSummary.textContent = t("extensions-hub-connections.scope-controls"); const scopeBody = document.createElement("div"); scopeBody.className = "pi-hub-advanced-body"; const sessionToggleRow = createToggleRow({ - label: "Enable for this session", + label: t("ext-hub-connections.enableSession"), checked: webSearchSessionEnabled, onChange: (checked) => { if (!sessionId) { - showToast("No active session"); + showToast(t("extensions-hub-connections.toast.noActiveSession")); return; } void runMutation(async () => { @@ -389,12 +390,12 @@ export async function renderConnectionsTab(args: { const workbookToggleRow = createToggleRow({ label: workbookId - ? `Enable for workbook (${workbookContext.workbookLabel})` - : "Workbook scope unavailable", + ? t("ext-hub-connections.enableWorkbook", { label: workbookContext.workbookLabel }) + : t("ext-hub-connections.scopeUnavailable"), checked: webSearchWorkbookEnabled, onChange: (checked) => { if (!workbookId) { - showToast("Workbook scope unavailable"); + showToast(t("extensions-hub-connections.toast.workbookScopeUnavailable")); return; } void runMutation(async () => { @@ -429,8 +430,8 @@ export async function renderConnectionsTab(args: { const mcpAddVisible = { value: false }; const mcpHeader = createSectionHeader({ - label: "MCP servers", - actionLabel: "+ Add server", + label: t("extensions-hub-connections.mcpSection"), + actionLabel: t("extensions-hub-connections.addServer"), onAction: () => { mcpAddVisible.value = !mcpAddVisible.value; mcpAddForm.hidden = !mcpAddVisible.value; @@ -442,7 +443,7 @@ export async function renderConnectionsTab(args: { mcpList.className = "pi-hub-stack"; if (mcpServers.length === 0) { - mcpList.appendChild(createEmptyInline(lucide(Zap), "No MCP servers configured.\nAdd one to connect external tools.")); + mcpList.appendChild(createEmptyInline(lucide(Zap), t("ext-hub-connections.noMcpServers"))); } else { for (const server of mcpServers) { mcpList.appendChild(renderMcpServerCard(server, settings, isBusy, runMutation)); @@ -451,16 +452,16 @@ export async function renderConnectionsTab(args: { container.appendChild(mcpList); // MCP add form (hidden by default) - const nameInput = createAddFormInput("Server name"); - const urlInput = createAddFormInput("https://server-url/rpc"); - const tokenInput = createAddFormInput("Bearer token (optional)"); + const nameInput = createAddFormInput(t("ext-hub-connections.serverNamePlaceholder")); + const urlInput = createAddFormInput(t("ext-hub-connections.serverUrlPlaceholder")); + const tokenInput = createAddFormInput(t("ext-hub-connections.bearerTokenPlaceholder")); tokenInput.type = "password"; const addRow = createAddFormRow(); addRow.append(nameInput, urlInput); const tokenRow = createAddFormRow(); - tokenRow.append(tokenInput, createButton("Add", { + tokenRow.append(tokenInput, createButton(t("ext-hub-connections.addButton"), { primary: true, compact: true, onClick: () => { @@ -489,7 +490,7 @@ export async function renderConnectionsTab(args: { const showTmux = true; if (showPython || showTmux) { - container.appendChild(createSectionHeader({ label: "Bridges" })); + container.appendChild(createSectionHeader({ label: t("extensions-hub-connections.bridgesSection") })); const bridgeList = document.createElement("div"); bridgeList.className = "pi-hub-stack"; @@ -497,8 +498,8 @@ export async function renderConnectionsTab(args: { if (showPython) { bridgeList.appendChild(renderBridgeCard({ icon: lucide(Terminal), - name: "Python bridge", - description: "Execute Python code in a local environment", + name: t("ext-hub-connections.pythonName"), + description: t("ext-hub-connections.pythonDesc"), settingKey: PYTHON_BRIDGE_URL_SETTING_KEY, setupCommand: "npx pi-for-excel-python-bridge", defaultUrl: DEFAULT_PYTHON_BRIDGE_URL, @@ -513,8 +514,8 @@ export async function renderConnectionsTab(args: { if (showTmux) { bridgeList.appendChild(renderBridgeCard({ icon: lucide(Terminal), - name: "tmux bridge", - description: "Remote shell sessions via tmux", + name: t("ext-hub-connections.tmuxName"), + description: t("ext-hub-connections.tmuxDesc"), settingKey: TMUX_BRIDGE_URL_SETTING_KEY, setupCommand: "npx pi-for-excel-tmux-bridge", defaultUrl: DEFAULT_TMUX_BRIDGE_URL, @@ -538,7 +539,7 @@ function renderMcpServerCard( isBusy: () => boolean, runMutation: (action: () => Promise, reason: "toggle" | "scope" | "external-toggle" | "config", msg?: string) => Promise, ): HTMLElement { - const toolLabel = server.enabled ? "Enabled" : "Disabled"; + const toolLabel = server.enabled ? t("ext-hub-connections.badgeEnabled") : t("ext-hub-connections.badgeDisabled"); const card = createItemCard({ icon: lucide(Zap), iconColor: "blue", @@ -552,7 +553,7 @@ function renderMcpServerCard( card.body.appendChild(createConfigRow("URL", createConfigValue(server.url))); // Token - const tokenValue = server.token ? maskSecret(server.token) : "(none)"; + const tokenValue = server.token ? maskSecret(server.token) : t("ext-hub-connections.badgeNoToken"); card.body.appendChild(createConfigRow("Token", createConfigValue(tokenValue))); // Enabled toggle @@ -560,7 +561,7 @@ function renderMcpServerCard( enabledRow.className = "pi-item-card__config-row"; const enabledLabel = document.createElement("span"); enabledLabel.className = "pi-item-card__config-label"; - enabledLabel.textContent = "Enabled"; + enabledLabel.textContent = t("extensions-hub-connections.enabled"); const enabledToggle = createToggle({ checked: server.enabled, onChange: (checked) => { @@ -577,7 +578,7 @@ function renderMcpServerCard( card.body.appendChild(enabledRow); // Actions - const testBtn = createButton("Test", { + const testBtn = createButton(t("ext-hub-connections.testButton"), { compact: true, onClick: () => { if (isBusy()) return; @@ -593,7 +594,7 @@ function renderMcpServerCard( }, }); - const removeBtn = createButton("Remove", { + const removeBtn = createButton(t("ext-hub-connections.removeButton"), { danger: true, compact: true, onClick: () => { @@ -632,21 +633,21 @@ function renderBridgeCard(args: { expandable: true, expanded: !args.hasCustomUrl, badges: [args.hasCustomUrl - ? { text: "Configured", tone: "ok" as const } - : { text: "Default URL", tone: "muted" as const }, + ? { text: t("ext-hub-connections.configured"), tone: "ok" as const } + : { text: t("ext-hub-connections.defaultUrl"), tone: "muted" as const }, ], }); const setupLabel = document.createElement("p"); setupLabel.className = "pi-hub-bridge-setup__label"; - setupLabel.textContent = "Quick setup"; + setupLabel.textContent = t("extensions-hub-connections.quick-setup"); card.body.append(setupLabel, createBridgeSetupCommand(args.setupCommand)); const urlInput = createConfigInput({ value: args.currentUrl, placeholder: args.placeholder, }); - card.body.appendChild(createConfigRow("Bridge URL", urlInput)); + card.body.appendChild(createConfigRow(t("ext-hub-connections.bridgeUrl"), urlInput)); const saveBridgeUrl = (clear: boolean): void => { const candidateUrl = clear ? "" : urlInput.value.trim(); @@ -677,8 +678,8 @@ function renderBridgeCard(args: { }, "config", useDefaultUrl ? `${args.name} URL set to default` : `${args.name} URL saved`); }; - const saveBtn = createButton("Save", { compact: true, onClick: () => saveBridgeUrl(false) }); - const clearBtn = createButton("Clear", { compact: true, onClick: () => saveBridgeUrl(true) }); + const saveBtn = createButton(t("ext-hub-connections.saveButton"), { compact: true, onClick: () => saveBridgeUrl(false) }); + const clearBtn = createButton(t("ext-hub-connections.clearButton"), { compact: true, onClick: () => saveBridgeUrl(true) }); card.body.appendChild(createActionsRow(saveBtn, clearBtn)); return card.root; diff --git a/src/commands/builtins/extensions-hub-extension-connections.ts b/src/commands/builtins/extensions-hub-extension-connections.ts index 53cd7c4e..9cd3fcd9 100644 --- a/src/commands/builtins/extensions-hub-extension-connections.ts +++ b/src/commands/builtins/extensions-hub-extension-connections.ts @@ -5,6 +5,7 @@ * between the web search section and MCP servers. */ +import { t } from "../../language/index.js"; import type { ConnectionManager } from "../../connections/manager.js"; import type { ConnectionDefinition, ConnectionSnapshot, ConnectionStatus } from "../../connections/types.js"; import { @@ -18,6 +19,7 @@ import { } from "../../ui/extensions-hub-components.js"; import { lucide, AlertTriangle, Plug } from "../../ui/lucide-icons.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { formatRelativeDate } from "./overlay-relative-date.js"; // ── Badge mapping ─────────────────────────────────── @@ -119,7 +121,7 @@ function renderConnectionCard(args: { } if (Object.keys(patch).length === 0) { - showToast("Enter at least one field to save."); + showToast(t("extensions-hub-extension-connections.toast.enterAtLeastOneField")); return; } @@ -180,11 +182,11 @@ export async function renderExtensionConnectionsSection(args: { const definitions = connectionManager.listDefinitions(); - container.appendChild(createSectionHeader({ label: "Extension connections" })); + container.appendChild(createSectionHeader({ label: t("ext-hub-connections.extConnections") })); if (definitions.length === 0) { container.appendChild( - createEmptyInline(lucide(Plug), "Installed extensions haven't registered any connections."), + createEmptyInline(lucide(Plug), t("ext-hub-connections.connectionsEmpty")), ); return; } diff --git a/src/commands/builtins/extensions-hub-overlay.ts b/src/commands/builtins/extensions-hub-overlay.ts index 6ce6a8e8..d8c16e4b 100644 --- a/src/commands/builtins/extensions-hub-overlay.ts +++ b/src/commands/builtins/extensions-hub-overlay.ts @@ -16,6 +16,7 @@ import { } from "../../ui/overlay-dialog.js"; import { ADDONS_OVERLAY_ID } from "../../ui/overlay-ids.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { renderConnectionsTab } from "./extensions-hub-connections.js"; import { renderPluginsTab } from "./extensions-hub-plugins.js"; import { createDeferredConnectionsRefreshController } from "./extensions-hub-refresh.js"; @@ -37,9 +38,9 @@ export interface ExtensionsHubDependencies { } const TABS: ReadonlyArray<{ id: ExtensionsHubTab; label: string }> = [ - { id: "connections", label: "Connections" }, - { id: "plugins", label: "Plugins" }, - { id: "skills", label: "Skills" }, + { id: "connections", label: t("extensions-hub.tabConnections") }, + { id: "plugins", label: t("extensions-hub.tabPlugins") }, + { id: "skills", label: t("extensions-hub.tabSkills") }, ]; let openInFlight: Promise | null = null; @@ -92,9 +93,9 @@ export async function showExtensionsHubDialog( const { header } = createOverlayHeader({ onClose: dialog.close, - closeLabel: "Close extensions", - title: "Extensions", - subtitle: "Connections, plugins, and skills that extend Pi", + closeLabel: t("extensions-hub.closeLabel"), + title: t("extensions-hub.title"), + subtitle: t("extensions-hub.subtitle"), }); // ── Tab bar ──────────────────────────────────── diff --git a/src/commands/builtins/extensions-hub-plugins.ts b/src/commands/builtins/extensions-hub-plugins.ts index 7d2a33cc..c6f33be9 100644 --- a/src/commands/builtins/extensions-hub-plugins.ts +++ b/src/commands/builtins/extensions-hub-plugins.ts @@ -18,6 +18,7 @@ import { } from "../../extensions/permissions.js"; import { requestConfirmationDialog } from "../../ui/confirm-dialog.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { createSectionHeader, createItemCard, @@ -116,12 +117,12 @@ export function renderPluginsTab(args: { // ── Installed section ───────────────────────── container.appendChild(createSectionHeader({ - label: "Installed", + label: t("ext-hub-plugins.installed"), count: statuses.length, })); if (statuses.length === 0) { - container.appendChild(createEmptyInline(lucide(Puzzle), "No plugins installed.\nPi can build plugins, or install one from a URL.")); + container.appendChild(createEmptyInline(lucide(Puzzle), t("ext-hub-plugins.empty"))); } else { const list = document.createElement("div"); list.className = "pi-hub-stack"; @@ -135,20 +136,20 @@ export function renderPluginsTab(args: { } // ── Install from URL ─────────────────────────── - container.appendChild(createSectionHeader({ label: "Install" })); + container.appendChild(createSectionHeader({ label: t("ext-hub-plugins.install") })); const installForm = createAddForm(); const urlRow = createAddFormRow(); - const urlInput = createAddFormInput("Paste a plugin URL…"); + const urlInput = createAddFormInput(t("ext-hub-plugins.pasteUrl")); urlRow.append( urlInput, - createButton("Install", { + createButton(t("ext-hub-plugins.installButton"), { primary: true, compact: true, onClick: () => { if (isBusy()) return; const url = urlInput.value.trim(); - if (!url) { showToast("Enter a URL first."); return; } + if (!url) { showToast(t("extensions-hub-plugins.toast.enterUrl")); return; } void installFromUrl(url, manager, onChanged, () => renderPluginsTab(args)); urlInput.value = ""; }, @@ -202,13 +203,13 @@ function renderPluginCard( // Commands if (status.commandNames.length > 0) { const cmds = status.commandNames.map((c: string) => `/${c}`).join(", "); - card.body.appendChild(createConfigRow("Commands", createConfigValue(cmds))); + card.body.appendChild(createConfigRow(t("ext-hub-plugins.commands"), createConfigValue(cmds))); } // Permissions grid const allCaps = listAllExtensionCapabilities(); if (allCaps.length > 0) { - card.body.appendChild(createSectionHeader({ label: "Permissions" })); + card.body.appendChild(createSectionHeader({ label: t("ext-hub-plugins.permissions") })); const grid = document.createElement("div"); grid.className = "pi-item-card__permissions"; @@ -247,7 +248,7 @@ function renderPluginCard( // Uninstall card.body.appendChild(createActionsRow( - createButton("Uninstall", { + createButton(t("ext-hub-plugins.uninstall"), { danger: true, compact: true, onClick: () => { diff --git a/src/commands/builtins/extensions-hub-skills.ts b/src/commands/builtins/extensions-hub-skills.ts index bf059174..180860cf 100644 --- a/src/commands/builtins/extensions-hub-skills.ts +++ b/src/commands/builtins/extensions-hub-skills.ts @@ -33,6 +33,7 @@ import { createToggle, } from "../../ui/extensions-hub-components.js"; import { lucide, ClipboardList } from "../../ui/lucide-icons.js"; +import { t } from "../../language/index.js"; // ── Types ─────────────────────────────────────────── @@ -124,12 +125,12 @@ export async function renderSkillsTab(args: { const statusLine = document.createElement("p"); statusLine.className = "pi-overlay-hint"; - statusLine.textContent = `${snapshot.active.length} skills active (${activeBundledCount} bundled, ${activeExternalCount} external)`; + statusLine.textContent = t("ext-hub-skills.statusLine", { active: snapshot.active.length, bundled: activeBundledCount, external: activeExternalCount }); container.appendChild(statusLine); // ── Bundled section ─────────────────────────── container.appendChild(createSectionHeader({ - label: "Bundled skills", + label: t("extensions-hub-skills.bundledSection"), count: snapshot.bundled.length, })); @@ -154,12 +155,12 @@ export async function renderSkillsTab(args: { // ── External section ────────────────────────── container.appendChild(createSectionHeader({ - label: "External skills", + label: t("extensions-hub-skills.externalSection"), count: snapshot.external.length, })); if (snapshot.external.length === 0) { - container.appendChild(createEmptyInline(lucide(ClipboardList), "No external skills installed.\nPaste a SKILL.md below to add one.")); + container.appendChild(createEmptyInline(lucide(ClipboardList), t("ext-hub-skills.noExternalSkills"))); } else { const list = document.createElement("div"); list.className = "pi-hub-stack"; @@ -209,29 +210,29 @@ export async function renderSkillsTab(args: { } // ── Install section ─────────────────────────── - container.appendChild(createSectionHeader({ label: "Install skill" })); + container.appendChild(createSectionHeader({ label: t("extensions-hub-skills.installSection") })); const installForm = createAddForm(); const hint = document.createElement("p"); hint.className = "pi-overlay-hint"; - hint.textContent = "Paste a SKILL.md document below to install an external skill."; + hint.textContent = t("extensions-hub-skills.install-hint"); installForm.appendChild(hint); const textarea = document.createElement("textarea"); textarea.className = "pi-overlay-input pi-hub-textarea"; - textarea.placeholder = "---\nname: my-skill\ndescription: What this skill does\n---\n\nInstructions for the agent..."; + textarea.placeholder = "---\\nname: my-skill\\ndescription: What this skill does\\n---\\n\\n" + t("extensions-hub-skills.installPlaceholder"); installForm.appendChild(textarea); const installActions = document.createElement("div"); installActions.className = "pi-hub-actions-end"; - installActions.appendChild(createButton("Install skill", { + installActions.appendChild(createButton(t("extensions-hub-skills.installButton"), { primary: true, compact: true, onClick: () => { if (isBusy()) return; const md = textarea.value.trim(); - if (!md) { showToast("Paste a SKILL.md document first."); return; } + if (!md) { showToast(t("extensions-hub-skills.toast.pasteSkillMd")); return; } void runMutation(async () => { const result = await upsertExternalAgentSkillInWorkspace({ @@ -250,7 +251,7 @@ export async function renderSkillsTab(args: { // Footer hint const footer = document.createElement("p"); footer.className = "pi-overlay-hint"; - footer.textContent = "Skills are instruction documents the AI reads on-demand to learn new workflows. They don't run code — they teach."; + footer.textContent = t("extensions-hub-skills.footer-hint"); container.appendChild(footer); } @@ -274,7 +275,7 @@ function renderBundledSkillCard(args: { iconColor: "amber", name: args.skill.name, description: args.skill.description, - badges: [{ text: "Bundled", tone: "muted" }], + badges: [{ text: t("extensions-hub-skills.bundledBadge"), tone: "muted" }], rightContent: toggle.root, }); @@ -292,7 +293,7 @@ function renderExternalSkillCard(args: { onRemove: () => void; }): HTMLElement { const badges: Array<{ text: string; tone: "ok" | "warn" | "muted" | "info" }> = [ - { text: "External", tone: "info" }, + { text: t("extensions-hub-skills.externalBadge"), tone: "info" }, ]; if (args.shadowed) { badges.push({ text: "Shadowed", tone: "warn" }); diff --git a/src/commands/builtins/files.ts b/src/commands/builtins/files.ts index 6999d883..42fc0f59 100644 --- a/src/commands/builtins/files.ts +++ b/src/commands/builtins/files.ts @@ -3,6 +3,7 @@ */ import type { SlashCommand } from "../types.js"; +import { t } from "../../language/index.js"; export interface FilesCommandActions { openFilesWorkspace: () => void | Promise; @@ -12,7 +13,7 @@ export function createFilesCommands(actions: FilesCommandActions): SlashCommand[ return [ { name: "files", - description: "Browse workspace files", + description: t("command.files.desc"), source: "builtin", execute: () => { void actions.openFilesWorkspace(); diff --git a/src/commands/builtins/help.ts b/src/commands/builtins/help.ts index e27934b1..9e716ded 100644 --- a/src/commands/builtins/help.ts +++ b/src/commands/builtins/help.ts @@ -4,12 +4,13 @@ import type { SlashCommand } from "../types.js"; import { showShortcutsDialog } from "./overlays.js"; +import { t } from "../../language/index.js"; export function createHelpCommands(): SlashCommand[] { return [ { name: "shortcuts", - description: "Show keyboard shortcuts", + description: t("command.help.desc"), source: "builtin", execute: () => { showShortcutsDialog(); diff --git a/src/commands/builtins/model.ts b/src/commands/builtins/model.ts index d4b1830f..1d380b64 100644 --- a/src/commands/builtins/model.ts +++ b/src/commands/builtins/model.ts @@ -6,6 +6,7 @@ import type { Agent } from "@earendil-works/pi-agent-core"; import type { SlashCommand } from "../types.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; export type ActiveAgentProvider = () => Agent | null; @@ -18,7 +19,7 @@ export function createModelCommands(actions: ModelCommandActions): SlashCommand[ const runModelSelector = (): void => { const agent = actions.getActiveAgent(); if (!agent) { - showToast("No active session"); + showToast(t("command.model.no_session")); return; } @@ -28,13 +29,13 @@ export function createModelCommands(actions: ModelCommandActions): SlashCommand[ return [ { name: "model", - description: "Change the AI model", + description: t("command.model.desc"), source: "builtin", execute: runModelSelector, }, { name: "default-models", - description: "Cycle models with Ctrl+P", + description: t("command.model.cycle"), source: "builtin", execute: () => { // TODO: implement scoped models dialog diff --git a/src/commands/builtins/overlay-relative-date.ts b/src/commands/builtins/overlay-relative-date.ts index f41f52fa..054b976d 100644 --- a/src/commands/builtins/overlay-relative-date.ts +++ b/src/commands/builtins/overlay-relative-date.ts @@ -2,14 +2,16 @@ * Shared relative date formatting for overlay lists. */ +import { t } from "../../language/index.js"; + export function formatRelativeDate(iso: string): string { const date = new Date(iso); const now = new Date(); const diff = now.getTime() - date.getTime(); - if (diff < 60_000) return "just now"; - if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`; - if (diff < 604_800_000) return `${Math.round(diff / 86_400_000)}d ago`; + if (diff < 60_000) return t("date.justNow"); + if (diff < 3_600_000) return t("date.minutesAgo", { n: Math.round(diff / 60_000) }); + if (diff < 86_400_000) return t("date.hoursAgo", { n: Math.round(diff / 3_600_000) }); + if (diff < 604_800_000) return t("date.daysAgo", { n: Math.round(diff / 86_400_000) }); return date.toLocaleDateString(); } diff --git a/src/commands/builtins/recovery-overlay.ts b/src/commands/builtins/recovery-overlay.ts index 2488139e..be443ddb 100644 --- a/src/commands/builtins/recovery-overlay.ts +++ b/src/commands/builtins/recovery-overlay.ts @@ -7,6 +7,7 @@ */ import { formatRelativeDate } from "./overlay-relative-date.js"; +import { t } from "../../language/index.js"; import { applyRecoveryFilters, buildToolFilterOptions, @@ -136,9 +137,9 @@ export async function showRecoveryDialog(opts: { const { header } = createOverlayHeader({ onClose: dialog.close, - closeLabel: "Close backups", - title: "Backups", - subtitle: "Snapshots saved before Pi changes your data", + closeLabel: t("recovery.close"), + title: t("recovery.title"), + subtitle: t("recovery.subtitle"), }); // -- Warning callout -- @@ -146,7 +147,7 @@ export async function showRecoveryDialog(opts: { const warningCallout = createCallout( "warn", lucide(AlertTriangle), - "Backups clear when you save this workbook in Excel.", + t("recovery.warning"), { compact: true }, ); @@ -157,7 +158,7 @@ export async function showRecoveryDialog(opts: { const searchInput = document.createElement("input"); searchInput.type = "text"; - searchInput.placeholder = "Search backups…"; + searchInput.placeholder = t("recovery.searchPlaceholder"); searchInput.className = "pi-recovery-search pi-overlay-inline-control"; const toolFilterSelect = document.createElement("select"); @@ -166,7 +167,7 @@ export async function showRecoveryDialog(opts: { const sortButton = document.createElement("button"); sortButton.type = "button"; sortButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-recovery-sort-btn"; - sortButton.textContent = "↓ Newest"; + sortButton.textContent = t("recovery.sortNewest"); searchRow.append(searchInput, toolFilterSelect, sortButton); @@ -178,7 +179,7 @@ export async function showRecoveryDialog(opts: { const toolbarActions = document.createElement("div"); toolbarActions.className = "pi-overlay-toolbar-actions"; - const downloadBackupBtn = createButton("Download backup", { + const downloadBackupBtn = createButton(t("recovery.downloadBackup"), { primary: true, compact: true, onClick: () => { @@ -187,7 +188,7 @@ export async function showRecoveryDialog(opts: { if (!createManualFullBackup) return; void (async () => { setBusy(true); - statusText.textContent = "Capturing…"; + statusText.textContent = t("recovery.capturing"); try { const backup = await createManualFullBackup(); showToast(`Backup downloaded: #${shortId(backup.id)} (${formatBytes(backup.sizeBytes)})`); @@ -195,7 +196,7 @@ export async function showRecoveryDialog(opts: { } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Backup failed: ${message}`); - statusText.textContent = "Backup failed"; + statusText.textContent = t("recovery.backupFailed"); } finally { setBusy(false); } @@ -204,19 +205,19 @@ export async function showRecoveryDialog(opts: { }); downloadBackupBtn.hidden = opts.onCreateManualFullBackup === undefined; - const refreshButton = createButton("Refresh", { + const refreshButton = createButton(t("recovery.refresh"), { compact: true, onClick: () => { if (busy) return; void (async () => { setBusy(true); - statusText.textContent = "Refreshing…"; + statusText.textContent = t("recovery.refreshing"); try { await reload(); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Refresh failed: ${message}`); - statusText.textContent = "Refresh failed"; + statusText.textContent = t("recovery.refreshFailed"); } finally { setBusy(false); } @@ -224,7 +225,7 @@ export async function showRecoveryDialog(opts: { }, }); - const clearButton = createButton("Clear all", { + const clearButton = createButton(t("recovery.clearAll"), { danger: true, compact: true, onClick: () => { @@ -240,7 +241,7 @@ export async function showRecoveryDialog(opts: { }); if (!proceed || busy) return; setBusy(true); - statusText.textContent = "Clearing…"; + statusText.textContent = t("recovery.clearing"); try { const removed = await opts.onClear(); showToast(`Cleared ${removed} backup${removed === 1 ? "" : "s"}`); @@ -248,7 +249,7 @@ export async function showRecoveryDialog(opts: { } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Clear failed: ${message}`); - statusText.textContent = "Clear failed"; + statusText.textContent = t("recovery.clearFailed"); } finally { setBusy(false); } @@ -272,7 +273,7 @@ export async function showRecoveryDialog(opts: { const retentionSummary = document.createElement("summary"); retentionSummary.className = "pi-recovery-retention-summary"; - retentionSummary.textContent = "Retention settings"; + retentionSummary.textContent = t("recovery.retentionSettings"); retentionDetails.appendChild(retentionSummary); const retentionRow = document.createElement("div"); @@ -280,7 +281,7 @@ export async function showRecoveryDialog(opts: { const retentionLabel = document.createElement("label"); retentionLabel.className = "pi-recovery-retention__label"; - retentionLabel.textContent = "Keep at most"; + retentionLabel.textContent = t("recovery.keepAtMost"); const retentionInput = document.createElement("input"); retentionInput.type = "number"; @@ -290,9 +291,9 @@ export async function showRecoveryDialog(opts: { const retentionSuffix = document.createElement("span"); retentionSuffix.className = "pi-recovery-retention__suffix"; - retentionSuffix.textContent = "backups"; + retentionSuffix.textContent = t("recovery.backupsSuffix"); - const retentionSave = createButton("Save", { + const retentionSave = createButton(t("recovery.retentionSave"), { compact: true, onClick: () => { if (busy) return; @@ -375,7 +376,7 @@ export async function showRecoveryDialog(opts: { toolFilterSelect.appendChild(el); } - sortButton.textContent = filterState.sortOrder === "newest" ? "↓ Newest" : "↑ Oldest"; + sortButton.textContent = filterState.sortOrder === "newest" ? t("recovery.sortNewest") : t("recovery.sortOldest"); }; const renderList = (): void => { @@ -388,7 +389,7 @@ export async function showRecoveryDialog(opts: { if (allCheckpoints.length === 0) { const empty = createEmptyInline( lucide(Package), - "No backups yet\nPi will save snapshots here before making changes to your data.", + t("recovery.emptyState"), ); list.appendChild(empty); statusText.textContent = ""; @@ -396,7 +397,7 @@ export async function showRecoveryDialog(opts: { } if (filtered.length === 0) { - const empty = createEmptyInline(lucide(Search), "No backups match the current filters."); + const empty = createEmptyInline(lucide(Search), t("recovery.emptyFilter")); list.appendChild(empty); statusText.textContent = `0 of ${allCheckpoints.length} shown`; return; @@ -423,14 +424,14 @@ export async function showRecoveryDialog(opts: { meta.className = "pi-recovery-item__meta"; meta.textContent = `${formatChangedLabel(checkpoint.changedCount)} · #${shortId(checkpoint.id)}`; - const restoreButton = createButton("Restore", { + const restoreButton = createButton(t("recovery.restore"), { primary: true, compact: true, onClick: () => { if (busy) return; void (async () => { setBusy(true); - statusText.textContent = "Restoring…"; + statusText.textContent = t("recovery.restoring"); try { await opts.onRestore(checkpoint.id); allCheckpoints = await opts.loadCheckpoints(); @@ -438,7 +439,7 @@ export async function showRecoveryDialog(opts: { } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Restore failed: ${message}`); - statusText.textContent = "Restore failed"; + statusText.textContent = t("recovery.restoreFailed"); } finally { setBusy(false); } @@ -446,7 +447,7 @@ export async function showRecoveryDialog(opts: { }, }); - const deleteButton = createButton("Delete", { + const deleteButton = createButton(t("recovery.delete"), { danger: true, compact: true, onClick: () => { @@ -462,18 +463,18 @@ export async function showRecoveryDialog(opts: { }); if (!proceed || busy) return; setBusy(true); - statusText.textContent = "Deleting…"; + statusText.textContent = t("recovery.deleting"); try { const deleted = await opts.onDelete(checkpoint.id); if (!deleted) { - showToast("Backup not found"); + showToast(t("recovery.backupNotFound")); } allCheckpoints = await opts.loadCheckpoints(); renderList(); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Delete failed: ${message}`); - statusText.textContent = "Delete failed"; + statusText.textContent = t("recovery.deleteFailed"); } finally { setBusy(false); } @@ -543,7 +544,7 @@ export async function showRecoveryDialog(opts: { dialog.mount(); setBusy(true); - statusText.textContent = "Loading…"; + statusText.textContent = t("recovery.loading"); try { if (opts.getRetentionConfig) { try { @@ -558,7 +559,7 @@ export async function showRecoveryDialog(opts: { } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; showToast(`Failed to load backups: ${message}`); - statusText.textContent = "Load failed"; + statusText.textContent = t("recovery.loadFailed"); } finally { setBusy(false); } diff --git a/src/commands/builtins/resume-overlay.ts b/src/commands/builtins/resume-overlay.ts index 4861c324..66d6b42e 100644 --- a/src/commands/builtins/resume-overlay.ts +++ b/src/commands/builtins/resume-overlay.ts @@ -20,6 +20,7 @@ import { requestConfirmationDialog } from "../../ui/confirm-dialog.js"; import { RESUME_OVERLAY_ID } from "../../ui/overlay-ids.js"; import { showToast } from "../../ui/toast.js"; import { formatWorkbookLabel, getWorkbookContext } from "../../workbook/context.js"; +import { t } from "../../language/index.js"; import { getSessionWorkbookId, partitionSessionIdsByWorkbook, @@ -46,11 +47,11 @@ function buildResumeListItem(session: SessionMetadata): HTMLButtonElement { const title = document.createElement("span"); title.className = "pi-resume-item__title"; - title.textContent = session.title || "Untitled"; + title.textContent = session.title || t("resume.defaultTitle"); const meta = document.createElement("span"); meta.className = "pi-resume-item__meta"; - meta.textContent = `${session.messageCount || 0} messages · ${formatRelativeDate(session.lastModified)}`; + meta.textContent = t("resume.sessionMeta", { count: session.messageCount || 0, date: formatRelativeDate(session.lastModified) }); button.append(title, meta); return button; @@ -65,11 +66,11 @@ function buildRecentlyClosedListItem(item: ResumeRecentlyClosedItem): HTMLButton const title = document.createElement("span"); title.className = "pi-resume-item__title"; - title.textContent = item.title || "Untitled"; + title.textContent = item.title || t("resume.defaultTitle"); const meta = document.createElement("span"); meta.className = "pi-resume-item__meta"; - meta.textContent = `Closed ${formatRelativeDate(item.closedAt)} · Reopens in new tab`; + meta.textContent = t("resume.recentlyClosedMeta", { date: formatRelativeDate(item.closedAt) }); button.append(title, meta); return button; @@ -88,7 +89,7 @@ function buildWorkbookFilterRow(opts: { checkbox.checked = opts.checked; const labelText = document.createElement("span"); - labelText.textContent = "Show sessions from all workbooks"; + labelText.textContent = t("resume.showAllWorkbooks"); const workbookHint = document.createElement("span"); workbookHint.className = "pi-resume-workbook-filter__hint"; @@ -121,7 +122,7 @@ export async function showResumeDialog(opts: { }; if (allSessions.length === 0 && getRecentlyClosedItems().length === 0) { - showToast("No previous sessions"); + showToast(t("resume.noPreviousSessions")); return; } @@ -156,9 +157,9 @@ export async function showResumeDialog(opts: { const { header } = createOverlayHeader({ onClose: closeOverlay, - closeLabel: "Close resume sessions", - title: "Resume Session", - subtitle: "Pick a session to resume in a new tab or the current one.", + closeLabel: t("resume.closeLabel"), + title: t("resume.title"), + subtitle: t("resume.subtitle"), }); const targetControls = document.createElement("div"); @@ -167,12 +168,12 @@ export async function showResumeDialog(opts: { const openInNewTabButton = document.createElement("button"); openInNewTabButton.type = "button"; openInNewTabButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-resume-target-btn"; - openInNewTabButton.textContent = "Open in new tab"; + openInNewTabButton.textContent = t("resume.openInNewTab"); const replaceCurrentButton = document.createElement("button"); replaceCurrentButton.type = "button"; replaceCurrentButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-resume-target-btn"; - replaceCurrentButton.textContent = "Replace current"; + replaceCurrentButton.textContent = t("resume.replaceCurrent"); const targetHint = document.createElement("div"); targetHint.className = "pi-resume-target-hint"; @@ -186,11 +187,11 @@ export async function showResumeDialog(opts: { const recentTitle = document.createElement("h3"); recentTitle.className = "pi-overlay-section-title"; - recentTitle.textContent = "Recently closed"; + recentTitle.textContent = t("resume.recentlyClosed"); const recentHint = document.createElement("p"); recentHint.className = "pi-overlay-hint pi-resume-section-hint"; - recentHint.textContent = "Reopen a recently closed tab directly in a new tab."; + recentHint.textContent = t("resume.recentlyClosedHint"); const recentList = document.createElement("div"); recentList.className = "pi-resume-list pi-resume-list--recent"; @@ -203,7 +204,7 @@ export async function showResumeDialog(opts: { const savedTitle = document.createElement("h3"); savedTitle.className = "pi-overlay-section-title"; - savedTitle.textContent = "Saved sessions"; + savedTitle.textContent = t("resume.savedSessions"); const list = document.createElement("div"); list.className = "pi-resume-list"; @@ -219,7 +220,7 @@ export async function showResumeDialog(opts: { openInNewTabButton.setAttribute("aria-pressed", String(isNewTab)); replaceCurrentButton.setAttribute("aria-pressed", String(!isNewTab)); - targetHint.textContent = `Default action: ${getResumeTargetLabel(selectedTarget)}`; + targetHint.textContent = t("resume.defaultActionHint", { action: getResumeTargetLabel(selectedTarget) }); }; openInNewTabButton.addEventListener("click", () => { @@ -283,7 +284,7 @@ export async function showResumeDialog(opts: { const sessionData = await storage.sessions.loadSession(item.sessionId); if (!sessionData) { - showToast("Couldn't reopen session"); + showToast(t("resume.couldNotReopen")); return false; } @@ -309,7 +310,7 @@ export async function showResumeDialog(opts: { if (sessions.length === 0) { const empty = document.createElement("div"); empty.className = "pi-overlay-empty pi-resume-list-empty"; - empty.textContent = "No sessions available for this workbook."; + empty.textContent = t("resume.noSessionsForWorkbook"); list.appendChild(empty); return; } @@ -340,14 +341,14 @@ export async function showResumeDialog(opts: { if (kind === RESUME_ITEM_KIND_RECENTLY_CLOSED) { const recentId = item.dataset.recentId; if (!recentId) { - showToast("Session is no longer in recently closed"); + showToast(t("resume.sessionNotInRecentlyClosed")); renderLists(); return; } const recentEntry = recentlyClosedById.get(recentId); if (!recentEntry) { - showToast("Session is no longer in recently closed"); + showToast(t("resume.sessionNotInRecentlyClosed")); renderLists(); return; } @@ -380,7 +381,7 @@ export async function showResumeDialog(opts: { const sessionData = await storage.sessions.loadSession(id); if (!sessionData) { - showToast("Session not found"); + showToast(t("resume.sessionNotFound")); closeOverlay(); return; } diff --git a/src/commands/builtins/resume-target.ts b/src/commands/builtins/resume-target.ts index 57c4431a..4e3ce797 100644 --- a/src/commands/builtins/resume-target.ts +++ b/src/commands/builtins/resume-target.ts @@ -4,18 +4,20 @@ export type ResumeDialogTarget = "new_tab" | "replace_current"; +import { t } from "../../language/index.js"; + export function getResumeTargetLabel(target: ResumeDialogTarget): string { if (target === "replace_current") { - return "Replace current tab"; + return t("resume.replaceCurrent"); } - return "Open in new tab"; + return t("resume.openInNewTab"); } export function getCrossWorkbookResumeConfirmMessage(target: ResumeDialogTarget): string { if (target === "replace_current") { - return "This session was created for a different workbook. Resume anyway and replace the current chat?"; + return t("resume.crossWorkbookReplace"); } - return "This session was created for a different workbook. Resume anyway in a new tab?"; + return t("resume.crossWorkbookNewTab"); } diff --git a/src/commands/builtins/rules-overlay.ts b/src/commands/builtins/rules-overlay.ts index f57f5ddb..15c802b9 100644 --- a/src/commands/builtins/rules-overlay.ts +++ b/src/commands/builtins/rules-overlay.ts @@ -36,6 +36,7 @@ import { createOverlayHeader, } from "../../ui/overlay-dialog.js"; import { RULES_OVERLAY_ID } from "../../ui/overlay-ids.js"; +import { t } from "../../language/index.js"; import { showToast } from "../../ui/toast.js"; import { formatWorkbookLabel, getWorkbookContext } from "../../workbook/context.js"; @@ -50,6 +51,11 @@ const BUILTIN_PRESET_NAMES: NumberPreset[] = [ "text", ]; +function getPresetLabel(presetName: string): string { + const key = `rules.preset.${presetName}` as const; + return t(key); +} + function setActiveTab( tabButtons: Record, activeTab: RulesTab, @@ -68,7 +74,7 @@ function setActiveTab( } function formatCounterLabel(chars: number, limit: number): string { - return `${chars.toLocaleString()} / ${limit.toLocaleString()} chars`; + return t("rules.charsCount", { chars: chars.toLocaleString(), limit: limit.toLocaleString() }); } function el( @@ -183,15 +189,15 @@ function createHeaderPreview( const line1 = document.createElementNS(SVG_NS, "tspan"); line1.setAttribute("x", String(x + 8)); line1.setAttribute("dy", "0"); - line1.textContent = "Cost of Goods"; + line1.textContent = t("rules-overlay.cost-of-goods"); const line2 = document.createElementNS(SVG_NS, "tspan"); line2.setAttribute("x", String(x + 8)); line2.setAttribute("dy", "14"); - line2.textContent = "Sold"; + line2.textContent = t("rules-overlay.sold"); text.append(line1, line2); } else { text.setAttribute("y", "30"); - text.textContent = label === "Cost of Goods Sold" ? "Cost of Goods…" : label; + text.textContent = label === "Cost of Goods Sold" ? t("rules-overlay.cost-of-goods-ellipsis") : label; } svg.append(rect, text); @@ -570,7 +576,7 @@ function renderFormatCard(args: { const builtinPresetName = args.presetName; const restore = el("button", "pi-conventions-link-btn"); restore.type = "button"; - restore.textContent = "Custom format — use quick options to reset"; + restore.textContent = t("rules-overlay.custom-format-tip"); restore.addEventListener("click", () => { const builtDefault = DEFAULT_PRESET_FORMATS[builtinPresetName]; args.preset.builderParams = { @@ -585,7 +591,7 @@ function renderFormatCard(args: { if (args.onRemove) { const removeButton = el("button", "pi-conventions-link-btn pi-conventions-link-btn--danger"); removeButton.type = "button"; - removeButton.textContent = "Remove preset"; + removeButton.textContent = t("rules-overlay.remove-preset"); removeButton.addEventListener("click", () => { args.onRemove?.(); args.onChange(); @@ -608,7 +614,7 @@ function renderConventionsEditor( const formatsSection = el("section", "pi-conventions-section"); const formatsTitle = el("h3", "pi-conventions-section-title"); - formatsTitle.textContent = "Number formats"; + formatsTitle.textContent = t("rules-overlay.number-formats"); formatsSection.append(formatsTitle); const presetFormats = getPresetSection(draft); @@ -622,7 +628,7 @@ function renderConventionsEditor( presetFormats[presetName] = preset; formatsSection.appendChild(renderFormatCard({ - title: presetName, + title: getPresetLabel(presetName), presetName, preset, onChange: requestRerender, @@ -654,7 +660,7 @@ function renderConventionsEditor( const addCustomButton = el("button", "pi-overlay-btn pi-overlay-btn--ghost"); addCustomButton.type = "button"; - addCustomButton.textContent = "Add custom format"; + addCustomButton.textContent = t("rules-overlay.add-custom-format"); addCustomButton.addEventListener("click", () => { addCustomPreset(draft); requestRerender(); @@ -663,7 +669,7 @@ function renderConventionsEditor( const colorsSection = el("section", "pi-conventions-section"); const colorsTitle = el("h3", "pi-conventions-section-title"); - colorsTitle.textContent = "Colors (font color)"; + colorsTitle.textContent = t("rules-overlay.colors-font-color"); colorsSection.append(colorsTitle); const colorConventions = getColorConventions(draft); @@ -708,7 +714,7 @@ function renderConventionsEditor( const headerSection = el("section", "pi-conventions-section"); const headerTitle = el("h3", "pi-conventions-section-title"); - headerTitle.textContent = "Header style"; + headerTitle.textContent = t("rules-overlay.header-style"); headerSection.append(headerTitle); const headerStyle = getHeaderStyle(draft); @@ -754,7 +760,7 @@ function renderConventionsEditor( const visualSection = el("section", "pi-conventions-section"); const visualTitle = el("h3", "pi-conventions-section-title"); - visualTitle.textContent = "Default font"; + visualTitle.textContent = t("rules-overlay.default-font"); visualSection.append(visualTitle); const visualDefaults = getVisualDefaults(draft); @@ -813,8 +819,8 @@ export async function showRulesDialog(opts?: { const { header } = createOverlayHeader({ onClose: closeOverlay, closeLabel: "Close rules", - title: "Rules", - subtitle: "Set guidance for all files, this workbook, and formatting conventions.", + title: t("rules.title"), + subtitle: t("rules.subtitle"), }); const tabs = document.createElement("div"); @@ -823,19 +829,19 @@ export async function showRulesDialog(opts?: { const userTab = document.createElement("button"); userTab.type = "button"; - userTab.textContent = "All my files"; + userTab.textContent = t("rules-overlay.all-my-files"); userTab.className = "pi-overlay-tab"; userTab.setAttribute("role", "tab"); const workbookTab = document.createElement("button"); workbookTab.type = "button"; - workbookTab.textContent = "This file"; + workbookTab.textContent = t("rules-overlay.this-file"); workbookTab.className = "pi-overlay-tab"; workbookTab.setAttribute("role", "tab"); const conventionsTab = document.createElement("button"); conventionsTab.type = "button"; - conventionsTab.textContent = "Formats"; + conventionsTab.textContent = t("rules-overlay.formats"); conventionsTab.className = "pi-overlay-tab"; conventionsTab.setAttribute("role", "tab"); @@ -869,12 +875,12 @@ export async function showRulesDialog(opts?: { const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; - cancelBtn.textContent = "Cancel"; + cancelBtn.textContent = t("rules-overlay.cancel"); cancelBtn.className = "pi-overlay-btn pi-overlay-btn--ghost"; const saveBtn = document.createElement("button"); saveBtn.type = "button"; - saveBtn.textContent = "Save"; + saveBtn.textContent = t("rules-overlay.save"); saveBtn.className = "pi-overlay-btn pi-overlay-btn--primary"; actions.append(cancelBtn, saveBtn); @@ -902,14 +908,14 @@ export async function showRulesDialog(opts?: { if (activeTab === "user") { textarea.value = userDraft; textarea.placeholder = - "Your preferences and habits, e.g.\n• Always use EUR for currencies\n• Format dates as dd-mmm-yyyy\n• Check circular references after writes"; + t("rules.placeholder.user"); const count = userDraft.length; counter.textContent = formatCounterLabel(count, USER_RULES_SOFT_LIMIT); counter.classList.toggle("is-warning", count > USER_RULES_SOFT_LIMIT); hint.textContent = - "Guidance given to Pi in all your conversations. Pi can also update these when you tell it your preferences — e.g. \"always use EUR\"."; + t("rules.guidance.default"); workbookTag.hidden = true; return; } @@ -917,22 +923,22 @@ export async function showRulesDialog(opts?: { if (activeTab === "workbook") { textarea.value = workbookDraft; textarea.placeholder = - "Notes about this workbook's structure, e.g.\n• DCF model for Acme Corp, FY2025\n• Revenue assumptions in Inputs!B5:B15\n• Don't modify the Summary sheet"; + t("rules.placeholder.workbook"); const count = workbookDraft.length; counter.textContent = formatCounterLabel(count, WORKBOOK_RULES_SOFT_LIMIT); counter.classList.toggle("is-warning", count > WORKBOOK_RULES_SOFT_LIMIT); hint.textContent = !workbookId - ? "Can't identify this workbook right now — try saving the file first." - : "Guidance given to Pi only when it reads this file."; + ? t("rules.hint.workbook.no_id") + : t("rules.guidance.workbook"); workbookTag.hidden = false; return; } workbookTag.hidden = true; - hint.textContent = "Set preset formats, font colors, header style, and default font."; + hint.textContent = t("rules-overlay.hint-formats"); rerenderConventions(); }; @@ -993,7 +999,7 @@ export async function showRulesDialog(opts?: { await opts.onSaved(); } - showToast("Rules saved"); + showToast(t("rules.toast.saved")); closeOverlay(); })(); }); diff --git a/src/commands/builtins/session.ts b/src/commands/builtins/session.ts index 5cea4501..48ccb026 100644 --- a/src/commands/builtins/session.ts +++ b/src/commands/builtins/session.ts @@ -6,6 +6,7 @@ import type { SlashCommand } from "../types.js"; import type { ResumeDialogTarget } from "./resume-target.js"; import { requestConfirmationDialog } from "../../ui/confirm-dialog.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; export interface ManualFullBackupSummary { id: string; @@ -30,25 +31,25 @@ export function createSessionIdentityCommands(actions: SessionCommandActions): S return [ { name: "name", - description: "Name the current chat session", + description: t("command.session.name"), source: "builtin", execute: async (args: string) => { const title = args.trim(); if (!title) { - showToast("Usage: /name My Session Name"); + showToast(t("session.toast.name_usage")); return; } await actions.renameActiveSession(title); - showToast(`Session named: ${title}`); + showToast(t("session.toast.named", { title })); }, }, { name: "share-session", - description: "Share session as a link", + description: t("command.session.share"), source: "builtin", execute: () => { - showToast("Session sharing coming soon"); + showToast(t("session.toast.sharing_soon")); }, }, ]; @@ -80,14 +81,14 @@ function shortBackupId(id: string): string { } function backupUsage(): string { - return "Usage: /backup [create|list [limit]|restore [id]|clear]"; + return t("session.toast.backup_usage"); } export function createSessionLifecycleCommands(actions: SessionCommandActions): SlashCommand[] { return [ { name: "new", - description: "Start a new chat session tab", + description: t("command.session.new"), source: "builtin", execute: async () => { await actions.createRuntime(); @@ -95,7 +96,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): }, { name: "resume", - description: "Resume a previous session (opens in new tab)", + description: t("command.session.resume"), source: "builtin", execute: async () => { await actions.openResumeDialog("new_tab"); @@ -111,7 +112,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): }, { name: "history", - description: "Open Backups", + description: t("command.session.backups"), source: "builtin", execute: async () => { await actions.openRecoveryDialog(); @@ -119,7 +120,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): }, { name: "backup", - description: "Manual full-workbook backup (create/list/restore/clear)", + description: t("command.session.backup"), source: "builtin", execute: async (rawArgs: string) => { try { @@ -136,7 +137,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): if (action === "create") { const backup = await actions.createManualFullBackup(); showToast( - `Manual backup created: #${shortBackupId(backup.id)} (${formatBytes(backup.sizeBytes)}).`, + t("session.toast.backup_created", { id: shortBackupId(backup.id), size: formatBytes(backup.sizeBytes) }), ); return; } @@ -149,7 +150,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): const backups = await actions.listManualFullBackups(limit); if (backups.length === 0) { - showToast("No manual full-workbook backups for this workbook."); + showToast(t("session.toast.no_backups")); return; } @@ -160,27 +161,27 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): const hasMore = backups.length > 3; const previewText = hasMore ? `${preview}, …` : preview; - showToast(`Manual backups (${backups.length} shown): ${previewText}`); + showToast(t("session.toast.backups_list", { count: String(backups.length), preview: previewText })); return; } if (action === "restore") { const restored = await actions.restoreManualFullBackup(tailText.length > 0 ? tailText : undefined); if (!restored) { - showToast("Backup not found for this workbook."); + showToast(t("session.toast.backup_not_found")); return; } - showToast(`Downloaded backup #${shortBackupId(restored.id)}. Open the file in Excel to restore.`); + showToast(t("session.toast.backup_restored", { id: shortBackupId(restored.id) })); return; } if (action === "clear") { const proceed = await requestConfirmationDialog({ - title: "Delete all manual full-workbook backups?", - message: "This will remove all manual full-workbook backups for the active workbook.", - confirmLabel: "Delete all", - cancelLabel: "Cancel", + title: t("session.confirm.clear_title"), + message: t("session.confirm.clear_message"), + confirmLabel: t("session.confirm.clear_label"), + cancelLabel: t("session.confirm.cancel"), confirmButtonTone: "danger", restoreFocusOnClose: true, }); @@ -189,20 +190,20 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): } const removed = await actions.clearManualFullBackups(); - showToast(`Deleted ${removed} manual backup${removed === 1 ? "" : "s"}.`); + showToast(t("session.toast.backups_deleted", { count: String(removed) })); return; } showToast(backupUsage()); } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - showToast(`Backup command failed: ${message}`); + const message = error instanceof Error ? error.message : t("session.backup.unknown_error"); + showToast(t("session.toast.backup_failed", { message })); } }, }, { name: "reopen", - description: "Reopen the most recently closed session tab", + description: t("command.session.reopen"), source: "builtin", execute: async () => { await actions.reopenLastClosed(); @@ -210,7 +211,7 @@ export function createSessionLifecycleCommands(actions: SessionCommandActions): }, { name: "revert", - description: "Revert the latest workbook backup", + description: t("command.session.revert"), source: "builtin", execute: async () => { await actions.revertLatestCheckpoint(); diff --git a/src/commands/builtins/settings-overlay.ts b/src/commands/builtins/settings-overlay.ts index 4f58952e..d5f8ffbc 100644 --- a/src/commands/builtins/settings-overlay.ts +++ b/src/commands/builtins/settings-overlay.ts @@ -33,6 +33,7 @@ import { SETTINGS_OVERLAY_ID } from "../../ui/overlay-ids.js"; import { ALL_PROVIDERS, buildProviderRow } from "../../ui/provider-login.js"; import { showToast } from "../../ui/toast.js"; import { isRecord } from "../../utils/type-guards.js"; +import { t, initLanguage, getLanguage } from "../../language/index.js"; import { buildExperimentalFeatureContent, buildExperimentalFeatureFooter, @@ -86,10 +87,12 @@ interface ResolvedSectionFocus { anchor?: SettingsAnchor; } -const SETTINGS_TABS: ReadonlyArray<{ id: SettingsPrimaryTab; label: string }> = [ - { id: "logins", label: "Providers" }, - { id: "more", label: "More" }, -]; +function getSettingsTabs(): ReadonlyArray<{ id: SettingsPrimaryTab; label: string }> { + return [ + { id: "logins", label: t("settings.tab.providers") }, + { id: "more", label: t("settings.tab.more") }, + ]; +} let settingsDialogOpenInFlight: Promise | null = null; let pendingSectionFocus: SettingsOverlaySection | null = null; @@ -181,9 +184,9 @@ function createSectionShell(titleText: string, anchor: SettingsAnchor, hintText? async function buildProvidersSection(): Promise { const shell = createSectionShell( - "Providers", + t("settings.section.providers"), "providers", - "Connect providers to use their models.", + t("settings.section.providers.hint"), ); const providerList = document.createElement("div"); @@ -198,7 +201,7 @@ async function buildProvidersSection(): Promise { } catch { const warning = document.createElement("p"); warning.className = "pi-overlay-hint pi-overlay-text-warning"; - warning.textContent = "Saved provider state is temporarily unavailable. You can still connect providers."; + warning.textContent = t("settings.warning.provider_state"); shell.content.appendChild(warning); } @@ -210,11 +213,11 @@ async function buildProvidersSection(): Promise { expandedRef, onConnected: (_row: HTMLElement, _id: string, label: string) => { document.dispatchEvent(new CustomEvent("pi:providers-changed")); - showToast(`${label} connected`); + showToast(t("settings.toast.connected", { label })); }, onDisconnected: (_row: HTMLElement, _id: string, label: string) => { document.dispatchEvent(new CustomEvent("pi:providers-changed")); - showToast(`${label} disconnected`); + showToast(t("settings.toast.disconnected", { label })); }, }); @@ -247,7 +250,7 @@ function resolveProxyCallout(args: { return { tone: "info", icon: "ℹ", - message: "Proxy disabled.", + message: t("settings.proxy.disabled"), }; } @@ -255,7 +258,7 @@ function resolveProxyCallout(args: { return { tone: "success", icon: "✓", - message: `Proxy connected at ${args.proxyUrl}`, + message: t("settings.proxy.connected", { url: args.proxyUrl }), }; } @@ -263,14 +266,14 @@ function resolveProxyCallout(args: { return { tone: "warn", icon: "⚠", - message: `Proxy enabled at ${args.proxyUrl}, but it is not reachable right now.`, + message: t("settings.section.proxy.not_reachable", { url: args.proxyUrl }), }; } return { tone: "info", icon: "…", - message: `Checking proxy at ${args.proxyUrl}…`, + message: t("settings.section.proxy.checking", { url: args.proxyUrl }), }; } @@ -278,7 +281,7 @@ function buildProxySection( settingsStore: SettingsStore, registerCleanup?: SettingsCleanupRegistrar, ): HTMLElement { - const shell = createSectionShell("Proxy", "proxy"); + const shell = createSectionShell(t("settings.section.proxy"), "proxy"); const card = document.createElement("div"); card.className = "pi-overlay-surface pi-settings-proxy-card"; @@ -290,8 +293,8 @@ function buildProxySection( let urlSaveTimer: ReturnType | null = null; const proxyToggle = createToggleRow({ - label: "Proxy", - sublabel: "Route API calls through a local proxy", + label: t("settings.section.proxy.label"), + sublabel: t("settings.section.proxy.sublabel"), checked: enabled, onChange: (checked) => { void saveProxyEnabled(checked); @@ -306,7 +309,7 @@ function buildProxySection( proxyUrlInput.classList.add("pi-settings-proxy-url"); proxyUrlInput.spellcheck = false; - const proxyUrlRow = createConfigRow("URL", proxyUrlInput); + const proxyUrlRow = createConfigRow(t("settings.section.proxy.url"), proxyUrlInput); proxyUrlRow.classList.add("pi-settings-proxy-url-row"); const statusHost = document.createElement("div"); @@ -333,11 +336,11 @@ function buildProxySection( enabled = !nextEnabled; proxyToggle.input.checked = enabled; updateStatus(); - showToast("Failed to save proxy setting."); + showToast(t("settings.toast.proxy_save_failed")); return; } - showToast(enabled ? "Proxy enabled." : "Proxy disabled."); + showToast(enabled ? t("settings.toast.proxy_enabled") : t("settings.toast.proxy_disabled")); }; const saveProxyUrl = async (): Promise => { @@ -348,9 +351,9 @@ function buildProxySection( try { normalizedUrl = validateOfficeProxyUrl(candidate); } catch (error: unknown) { - validationError = error instanceof Error ? error.message : "Invalid proxy URL."; + validationError = error instanceof Error ? error.message : t("settings.toast.proxy_url_invalid"); updateStatus(); - showToast(`Proxy URL not saved: ${validationError}`); + showToast(t("settings.toast.proxy_url_not_saved", { error: validationError })); return; } @@ -365,14 +368,14 @@ function buildProxySection( try { await settingsStore.set("proxy.url", normalizedUrl); } catch { - showToast("Failed to save proxy URL."); + showToast(t("settings.toast.proxy_url_save_failed")); return; } proxyUrl = normalizedUrl; proxyUrlInput.value = normalizedUrl; updateStatus(); - showToast("Proxy URL saved."); + showToast(t("settings.toast.proxy_url_saved")); }; const scheduleProxyUrlSave = (): void => { @@ -438,12 +441,15 @@ function buildProxySection( guideLink.href = PROXY_HELPER_DOCS_URL; guideLink.target = "_blank"; guideLink.rel = "noopener noreferrer"; - guideLink.textContent = "Install and setup guide"; + guideLink.textContent = t("settings.section.proxy.guide"); + guideLink.style.color = "var(--color-link, #3b82f6)"; helper.append( - "Recommended URL: ", + t("settings.section.proxy.recommended"), + " ", recommendedUrl, - ". Keep this on localhost. ", + t("settings.section.proxy.keep_localhost"), + " ", guideLink, ".", ); @@ -473,19 +479,19 @@ function buildProxySection( } function buildExecutionModeSection(registerCleanup?: SettingsCleanupRegistrar): HTMLElement { - const shell = createSectionShell("Execution mode", "execution-mode"); + const shell = createSectionShell(t("settings.section.execution_mode"), "execution-mode"); const card = document.createElement("div"); card.className = "pi-overlay-surface pi-settings-execution-card"; const autoModeToggle = createToggleRow({ - label: "Auto mode", - sublabel: "Pi applies changes immediately without asking", + label: t("settings.section.execution.auto_mode"), + sublabel: t("settings.section.execution.auto_sublabel"), }); const hint = document.createElement("p"); hint.className = "pi-overlay-hint pi-settings-execution-hint"; - hint.textContent = "When off, Pi asks before each change (Confirm mode)."; + hint.textContent = t("settings.section.execution.confirm_hint"); card.append(autoModeToggle.root, hint); shell.content.appendChild(card); @@ -512,11 +518,11 @@ function buildExecutionModeSection(registerCleanup?: SettingsCleanupRegistrar): void setExecutionMode(nextMode).then( () => { currentMode = nextMode; - showToast(nextMode === "yolo" ? "Auto mode." : "Confirm mode."); + showToast(nextMode === "yolo" ? t("settings.toast.auto_mode") : t("settings.toast.confirm_mode")); }, () => { autoModeToggle.input.checked = currentMode === "yolo"; - showToast("Couldn't update execution mode."); + showToast(t("settings.toast.execution_failed")); }, ).finally(() => { autoModeToggle.input.disabled = false; @@ -555,22 +561,22 @@ function buildMoreSection(registerCleanup?: SettingsCleanupRegistrar): HTMLEleme panel.className = "pi-settings-more"; const advanced = createSectionShell( - "Advanced", + t("settings.section.advanced"), "advanced", - "Power-user shortcuts for rules, backups, and keyboard shortcuts.", + t("settings.section.advanced.hint"), ); const modelSwitchCard = document.createElement("div"); modelSwitchCard.className = "pi-overlay-surface pi-settings-model-switch-card"; const modelSwitchToggle = createToggleRow({ - label: "Fork model switch into new tab", - sublabel: "For non-empty chats, open a cloned tab instead of switching this tab in place.", + label: t("settings.section.advanced.fork_label"), + sublabel: t("settings.section.advanced.fork_sublabel"), }); const modelSwitchHint = document.createElement("p"); modelSwitchHint.className = "pi-overlay-hint pi-settings-model-switch-hint"; - modelSwitchHint.textContent = "Default: switch in place (pi-mono parity)."; + modelSwitchHint.textContent = t("settings.section.advanced.fork_default"); modelSwitchCard.append(modelSwitchToggle.root, modelSwitchHint); advanced.content.appendChild(modelSwitchCard); @@ -597,13 +603,13 @@ function buildMoreSection(registerCleanup?: SettingsCleanupRegistrar): HTMLEleme currentBehavior = nextBehavior; showToast( nextBehavior === "fork" - ? "Model switch will open a new tab for non-empty chats." - : "Model switch will stay in the current tab.", + ? t("settings.toast.fork_on") + : t("settings.toast.fork_off"), ); }, () => { modelSwitchToggle.input.checked = currentBehavior === "fork"; - showToast("Couldn't update model switch setting."); + showToast(t("settings.toast.fork_failed")); }, ).finally(() => { modelSwitchToggle.input.disabled = false; @@ -614,9 +620,9 @@ function buildMoreSection(registerCleanup?: SettingsCleanupRegistrar): HTMLEleme const advancedActions = document.createElement("div"); advancedActions.className = "pi-overlay-actions pi-settings-advanced-actions"; - const rulesButton = createOverlayButton({ text: "Rules & conventions…" }); - const backupsButton = createOverlayButton({ text: "Backups…" }); - const shortcutsButton = createOverlayButton({ text: "Keyboard shortcuts…" }); + const rulesButton = createOverlayButton({ text: t("settings.button.rules") }); + const backupsButton = createOverlayButton({ text: t("settings.button.backups") }); + const shortcutsButton = createOverlayButton({ text: t("settings.button.shortcuts") }); rulesButton.disabled = !dependencies.openRulesDialog; backupsButton.disabled = !dependencies.openRecoveryDialog; @@ -635,10 +641,48 @@ function buildMoreSection(registerCleanup?: SettingsCleanupRegistrar): HTMLEleme advancedActions.append(rulesButton, backupsButton, shortcutsButton); advanced.content.appendChild(advancedActions); + // Language selector + const langCard = document.createElement("div"); + langCard.className = "pi-overlay-surface pi-settings-model-switch-card"; + + const langLabel = document.createElement("div"); + langLabel.className = "pi-toggle-row__label"; + langLabel.textContent = t("settings.section.language.label"); + + const langSelect = document.createElement("select"); + langSelect.className = "pi-item-card__config-select"; + const enOpt = document.createElement("option"); + enOpt.value = "en"; + enOpt.textContent = t("settings.section.language.en"); + const zhOpt = document.createElement("option"); + zhOpt.value = "zh-CN"; + zhOpt.textContent = "中文"; + langSelect.append(enOpt, zhOpt); + + langSelect.value = getLanguage(); + + langSelect.addEventListener("change", () => { + const newLang = langSelect.value; + initLanguage(newLang); + void (async () => { + try { + const storage = getAppStorage(); + await storage.settings.set("language", newLang); + showToast(t("settings.lang.reloading")); + setTimeout(() => location.reload(), 1000); + } catch { + showToast(t("settings.lang.saveFailed")); + } + })(); + }); + + langCard.append(langLabel, langSelect); + advanced.content.appendChild(langCard); + const experimental = createSectionShell( - "Experimental", + t("settings.section.experimental"), "experimental", - "Advanced and in-progress capabilities.", + t("settings.section.experimental.hint"), ); experimental.content.appendChild(buildExperimentalFeatureContent()); experimental.content.appendChild(buildExperimentalFeatureFooter()); @@ -685,9 +729,9 @@ export async function showSettingsDialog(options: ShowSettingsDialogOptions = {} const { header } = createOverlayHeader({ onClose: dialog.close, - closeLabel: "Close settings", - title: "Settings", - subtitle: "Providers, proxy, and preferences", + closeLabel: t("settings.close"), + title: t("settings.title"), + subtitle: t("settings.subtitle"), }); const body = document.createElement("div"); @@ -696,7 +740,7 @@ export async function showSettingsDialog(options: ShowSettingsDialogOptions = {} const tabs = document.createElement("div"); tabs.className = "pi-overlay-tabs"; tabs.setAttribute("role", "tablist"); - tabs.setAttribute("aria-label", "Settings tabs"); + tabs.setAttribute("aria-label", t("settings.tabs.aria")); const panels = document.createElement("div"); panels.className = "pi-settings-panels"; @@ -721,7 +765,7 @@ export async function showSettingsDialog(options: ShowSettingsDialogOptions = {} panels.append(loginsPanel, morePanel); - for (const tab of SETTINGS_TABS) { + for (const tab of getSettingsTabs()) { const button = document.createElement("button"); button.type = "button"; button.className = "pi-overlay-tab"; diff --git a/src/commands/builtins/settings.ts b/src/commands/builtins/settings.ts index ad9ed9e9..c87fa81d 100644 --- a/src/commands/builtins/settings.ts +++ b/src/commands/builtins/settings.ts @@ -7,6 +7,7 @@ import { formatExecutionModeLabel, toggleExecutionMode } from "../../execution/m import { showToast } from "../../ui/toast.js"; import type { SlashCommand } from "../types.js"; import { showSettingsDialog } from "./overlays.js"; +import { t } from "../../language/index.js"; export interface SettingsCommandActions { openInstructionsEditor: () => Promise; @@ -53,7 +54,7 @@ export function createSettingsCommands(actions: SettingsCommandActions): SlashCo return [ { name: "settings", - description: "Settings (providers and advanced options)", + description: t("command.settings.desc"), source: "builtin", execute: () => { void showSettingsDialog(); @@ -61,7 +62,7 @@ export function createSettingsCommands(actions: SettingsCommandActions): SlashCo }, { name: "login", - description: "Open provider settings", + description: t("command.settings.providers"), source: "builtin", execute: async () => { await showSettingsDialog({ section: "logins" }); @@ -69,7 +70,7 @@ export function createSettingsCommands(actions: SettingsCommandActions): SlashCo }, { name: "yolo", - description: "Toggle execution mode (Auto vs Confirm)", + description: t("command.settings.mode"), source: "builtin", execute: async (args: string) => { const command = parseExecutionModeArg(args); @@ -99,7 +100,7 @@ export function createSettingsCommands(actions: SettingsCommandActions): SlashCo }, { name: "rules", - description: "Edit rules for Pi (all files + this file)", + description: t("command.settings.rules"), source: "builtin", execute: async () => { await actions.openInstructionsEditor(); diff --git a/src/commands/builtins/shortcuts-overlay.ts b/src/commands/builtins/shortcuts-overlay.ts index 0b17de4b..564c9291 100644 --- a/src/commands/builtins/shortcuts-overlay.ts +++ b/src/commands/builtins/shortcuts-overlay.ts @@ -11,6 +11,7 @@ import { createOverlayHeader, } from "../../ui/overlay-dialog.js"; import { SHORTCUTS_OVERLAY_ID } from "../../ui/overlay-ids.js"; +import { t } from "../../language/index.js"; // --------------------------------------------------------------------------- // Platform detection @@ -47,44 +48,46 @@ function same(key: string, description: string): ShortcutEntry { return { mac: key, win: key, description }; } -const SHORTCUT_GROUPS: readonly ShortcutGroup[] = [ +function getShortcutGroups(): readonly ShortcutGroup[] { + return [ { - title: "Chat", + title: t("shortcuts.section.chat"), shortcuts: [ - same("Enter", "Send message"), - same("Enter (while streaming)", "Interrupt and redirect"), - { mac: "⌥ Enter", win: "Alt+Enter", description: "Queue follow-up" }, - { mac: "⌥ ↑", win: "Alt+↑", description: "Restore queued messages" }, - { mac: "⇧ Tab", win: "Shift+Tab", description: "Cycle thinking level" }, + same("Enter", t("shortcuts.desc.send")), + same(t("shortcuts.keys.enterStreaming"), t("shortcuts.desc.interrupt")), + { mac: "⌥ Enter", win: "Alt+Enter", description: t("shortcuts.desc.queue") }, + { mac: "⌥ ↑", win: "Alt+↑", description: t("shortcuts.desc.restore_queue") }, + { mac: "⇧ Tab", win: "Shift+Tab", description: t("shortcuts.desc.cycle_thinking") }, ], }, { - title: "Tabs", + title: t("shortcuts.section.tabs"), shortcuts: [ - { mac: "⌘ T", win: "Ctrl+T", description: "New tab" }, - { mac: "⌘ W", win: "Ctrl+W", description: "Close tab" }, - { mac: "⌘ ⇧ T", win: "Ctrl+Shift+T", description: "Reopen closed tab" }, - same("← →", "Switch tabs (exit input first)"), - { mac: "⌘ ⇧ [ / ⌘ ⇧ ]", win: "Ctrl+PgUp / PgDn", description: "Previous / next tab" }, + { mac: "⌘ T", win: "Ctrl+T", description: t("shortcuts.desc.new_tab") }, + { mac: "⌘ W", win: "Ctrl+W", description: t("shortcuts.desc.close_tab") }, + { mac: "⌘ ⇧ T", win: "Ctrl+Shift+T", description: t("shortcuts.desc.reopen_tab") }, + same("← →", t("shortcuts.desc.switch_tabs")), + { mac: "⌘ ⇧ [ / ⌘ ⇧ ]", win: "Ctrl+PgUp / PgDn", description: t("shortcuts.desc.prev_next_tab") }, ], }, { - title: "Navigation", + title: t("shortcuts.section.navigation"), shortcuts: [ - same("/", "Open command menu"), - same("↑ ↓", "Navigate menu items"), - same("F2", "Focus chat input"), - same("F6", "Toggle focus: sheet ↔ sidebar"), - { mac: "⇧ F6", win: "Shift+F6", description: "Toggle focus (reverse)" }, + same("/", t("shortcuts.desc.command_menu")), + same("↑ ↓", t("shortcuts.desc.navigate_menu")), + same("F2", t("shortcuts.desc.focus_input")), + same("F6", t("shortcuts.desc.toggle_focus")), + { mac: "⇧ F6", win: "Shift+F6", description: t("shortcuts.desc.toggle_focus_reverse") }, ], }, { - title: "System", + title: t("shortcuts.section.system"), shortcuts: [ - same("Esc", "Dismiss overlay / stop generation / exit input"), + same("Esc", t("shortcuts.desc.dismiss")), ], }, -]; + ]; +} // --------------------------------------------------------------------------- // Overlay @@ -103,15 +106,15 @@ export function showShortcutsDialog(): void { const { header } = createOverlayHeader({ onClose: dialog.close, - closeLabel: "Close keyboard shortcuts", - title: "Keyboard Shortcuts", - subtitle: "Quick reference for chat, tabs, navigation, and system shortcuts.", + closeLabel: t("shortcuts.close"), + title: t("shortcuts.title"), + subtitle: t("shortcuts.subtitle"), }); const list = document.createElement("div"); list.className = "pi-shortcuts-list"; - for (const group of SHORTCUT_GROUPS) { + for (const group of getShortcutGroups()) { const section = document.createElement("div"); section.className = "pi-shortcuts-section"; diff --git a/src/commands/builtins/skills.ts b/src/commands/builtins/skills.ts index e2bd68c0..19fc8503 100644 --- a/src/commands/builtins/skills.ts +++ b/src/commands/builtins/skills.ts @@ -4,6 +4,7 @@ import type { ExtensionsHubTab } from "./extensions-hub-overlay.js"; import type { SlashCommand } from "../types.js"; +import { t } from "../../language/index.js"; export interface SkillsCommandActions { openExtensionsHub: (tab?: ExtensionsHubTab) => void | Promise; @@ -13,7 +14,7 @@ export function createSkillsCommands(actions: SkillsCommandActions): SlashComman return [ { name: "skills", - description: "Browse available skills (alias for /extensions skills)", + description: t("command.skills.desc"), source: "builtin", execute: () => { void actions.openExtensionsHub("skills"); diff --git a/src/commands/builtins/tools.ts b/src/commands/builtins/tools.ts index 88d73a01..c3da55c2 100644 --- a/src/commands/builtins/tools.ts +++ b/src/commands/builtins/tools.ts @@ -8,6 +8,7 @@ import { } from "../../integrations/naming.js"; import type { ExtensionsHubTab } from "./extensions-hub-overlay.js"; import type { SlashCommand } from "../types.js"; +import { t } from "../../language/index.js"; export interface ToolsCommandActions { openExtensionsHub: (tab?: ExtensionsHubTab) => void | Promise; @@ -17,7 +18,7 @@ export function createToolsCommands(actions: ToolsCommandActions): SlashCommand[ return [ { name: TOOLS_COMMAND_NAME, - description: `Manage ${INTEGRATIONS_MANAGER_LABEL_LOWER} (alias for /extensions connections)`, + description: t("command.tools.desc", { label: INTEGRATIONS_MANAGER_LABEL_LOWER }), source: "builtin", execute: () => { void actions.openExtensionsHub("connections"); diff --git a/src/compat/thinking-duration.ts b/src/compat/thinking-duration.ts index 424fb8b3..8288f304 100644 --- a/src/compat/thinking-duration.ts +++ b/src/compat/thinking-duration.ts @@ -4,7 +4,7 @@ * Upstream thinking-block currently leaves completed labels as "Thinking…". * We patch finished blocks to: * - "Thought for Xs" / "Thought for Xm Xs" when timing is available - * - "Thought" fallback for restored history with no timing data + * - t("compat.thought") fallback for restored history with no timing data */ interface ThinkingBlockState { @@ -13,6 +13,8 @@ interface ThinkingBlockState { completedLabel: string | null; } +import { t } from "../language/index.js"; + const stateByBlock = new WeakMap(); let patchInstalled = false; @@ -111,7 +113,7 @@ function syncThinkingBlockLabel(block: HTMLElement): void { } else { const currentText = labelEl.textContent?.trim() ?? ""; if (looksLikeThinkingLabel(currentText)) { - nextLabel = "Thought"; + nextLabel = t("compat.thought"); state.completedLabel = nextLabel; } } diff --git a/src/execution/controller.ts b/src/execution/controller.ts index 77f8e599..a677122c 100644 --- a/src/execution/controller.ts +++ b/src/execution/controller.ts @@ -1,5 +1,6 @@ /** Runtime controller for persisted execution mode state. */ +import { t } from "../language/index.js"; import { dispatchExecutionModeChanged, formatExecutionModeLabel, @@ -43,7 +44,7 @@ export async function createExecutionModeController( toggleFromUi: async () => { const nextMode = toggleExecutionMode(mode); await applyMode(nextMode); - options.showToast?.(`${formatExecutionModeLabel(nextMode)} mode.`); + options.showToast?.(t("status.mode.toast", { mode: formatExecutionModeLabel(nextMode) })); }, }; } diff --git a/src/execution/mode.ts b/src/execution/mode.ts index 1d8dbb29..ffdac83f 100644 --- a/src/execution/mode.ts +++ b/src/execution/mode.ts @@ -46,8 +46,10 @@ export function toggleExecutionMode(mode: ExecutionMode): ExecutionMode { return mode === "yolo" ? "safe" : "yolo"; } +import { t } from "../language/index.js"; + export function formatExecutionModeLabel(mode: ExecutionMode): string { - return mode === "yolo" ? "Auto" : "Confirm"; + return mode === "yolo" ? t("status.mode.auto") : t("status.mode.confirm"); } export function dispatchExecutionModeChanged(mode: ExecutionMode): void { diff --git a/src/experiments/flags.ts b/src/experiments/flags.ts index 8d7a92ce..037641ef 100644 --- a/src/experiments/flags.ts +++ b/src/experiments/flags.ts @@ -5,6 +5,7 @@ * controls. Most are opt-in; some can be default-on with a persisted override. */ +import { t } from "../language/index.js"; import { ALLOW_REMOTE_EXTENSION_URLS_STORAGE_KEY } from "../commands/extension-source-policy.js"; import { dispatchExperimentalFeatureChanged } from "./events.js"; @@ -32,55 +33,57 @@ export interface ExperimentalFeatureDefinition { defaultEnabled?: boolean; } -const EXPERIMENTAL_FEATURES = [ - { - id: "ui_dark_mode", - slug: "dark-mode", - aliases: ["theme-dark", "ui-dark-mode"], - title: "Dark mode", - description: "Enable Office/theme-driven dark mode for the task pane UI.", - wiring: "wired", - storageKey: "pi.experimental.uiDarkMode", - }, - { - id: "remote_extension_urls", - slug: "remote-extension-urls", - aliases: ["remote-extensions", "extensions-urls"], - title: "Remote extension URLs", - description: "Allow loading extensions from remote http(s) URLs.", - warning: "Unsafe: remote extension code can read workbook data and credentials.", - wiring: "wired", - storageKey: ALLOW_REMOTE_EXTENSION_URLS_STORAGE_KEY, - }, - { - id: "extension_permission_gates", - slug: "extension-permissions", - aliases: ["extensions-permissions", "extension-capability-gates"], - title: "Extension permission gates", - description: "Enforce per-extension capability permissions when extensions activate.", - wiring: "wired", - storageKey: "pi.experimental.extensionPermissionGates", - }, - { - id: "extension_sandbox_runtime", - slug: "extension-sandbox-rollback", - aliases: ["extension-sandbox", "extensions-sandbox", "sandboxed-extensions", "extension-host-fallback"], - title: "Extension sandbox rollback", - description: "Temporarily route untrusted extensions back to host runtime (kill switch).", - warning: "Use only as a rollback path. Default behavior runs untrusted extensions in sandbox iframes.", - wiring: "wired", - storageKey: "pi.experimental.extensionSandboxHostFallback", - }, - { - id: "extension_widget_v2", - slug: "extension-widget-v2", - aliases: ["extensions-widget-v2", "widget-v2", "extension-widgets"], - title: "Extension widget API v2", - description: "Enable additive multi-widget lifecycle APIs (upsert/remove/clear) with deterministic placement.", - wiring: "wired", - storageKey: "pi.experimental.extensionWidgetV2", - }, -] as const satisfies readonly ExperimentalFeatureDefinition[]; +function getFeatures(): readonly ExperimentalFeatureDefinition[] { + return [ + { + id: "ui_dark_mode", + slug: "dark-mode", + aliases: ["theme-dark", "ui-dark-mode"], + title: t("experimental.dark_mode.title"), + description: t("experimental.dark_mode.desc"), + wiring: "wired", + storageKey: "pi.experimental.uiDarkMode", + }, + { + id: "remote_extension_urls", + slug: "remote-extension-urls", + aliases: ["remote-extensions", "extensions-urls"], + title: t("experimental.remote_urls.title"), + description: t("experimental.remote_urls.desc"), + warning: t("experimental.remote_urls.warning"), + wiring: "wired", + storageKey: ALLOW_REMOTE_EXTENSION_URLS_STORAGE_KEY, + }, + { + id: "extension_permission_gates", + slug: "extension-permissions", + aliases: ["extensions-permissions", "extension-capability-gates"], + title: t("experimental.permission_gates.title"), + description: t("experimental.permission_gates.desc"), + wiring: "wired", + storageKey: "pi.experimental.extensionPermissionGates", + }, + { + id: "extension_sandbox_runtime", + slug: "extension-sandbox-rollback", + aliases: ["extension-sandbox", "extensions-sandbox", "sandboxed-extensions", "extension-host-fallback"], + title: t("experimental.sandbox_rollback.title"), + description: t("experimental.sandbox_rollback.desc"), + warning: t("experimental.sandbox_rollback.warning"), + wiring: "wired", + storageKey: "pi.experimental.extensionSandboxHostFallback", + }, + { + id: "extension_widget_v2", + slug: "extension-widget-v2", + aliases: ["extensions-widget-v2", "widget-v2", "extension-widgets"], + title: t("experimental.widget_v2.title"), + description: t("experimental.widget_v2.desc"), + wiring: "wired", + storageKey: "pi.experimental.extensionWidgetV2", + }, + ]; +} export interface ExperimentalFeatureSnapshot extends ExperimentalFeatureDefinition { enabled: boolean; @@ -121,7 +124,7 @@ function safeSetItem(key: string, value: string): void { } function getFeatureDefinition(featureId: ExperimentalFeatureId): ExperimentalFeatureDefinition { - for (const feature of EXPERIMENTAL_FEATURES) { + for (const feature of getFeatures()) { if (feature.id === featureId) return feature; } @@ -129,18 +132,18 @@ function getFeatureDefinition(featureId: ExperimentalFeatureId): ExperimentalFea } export function listExperimentalFeatures(): readonly ExperimentalFeatureDefinition[] { - return EXPERIMENTAL_FEATURES; + return getFeatures(); } export function getExperimentalFeatureSlugs(): string[] { - return EXPERIMENTAL_FEATURES.map((feature) => feature.slug); + return getFeatures().map((feature) => feature.slug); } export function resolveExperimentalFeature(input: string): ExperimentalFeatureDefinition | null { const token = normalizeFeatureToken(input); if (!token) return null; - for (const feature of EXPERIMENTAL_FEATURES) { + for (const feature of getFeatures()) { if (token === normalizeFeatureToken(feature.slug)) { return feature; } @@ -190,7 +193,7 @@ export function toggleExperimentalFeature(featureId: ExperimentalFeatureId): boo } export function getExperimentalFeatureSnapshots(): ExperimentalFeatureSnapshot[] { - return EXPERIMENTAL_FEATURES.map((feature) => ({ + return getFeatures().map((feature) => ({ ...feature, enabled: isExperimentalFeatureEnabled(feature.id), })); diff --git a/src/extensions/permissions.ts b/src/extensions/permissions.ts index 64406e7e..d0fa22c5 100644 --- a/src/extensions/permissions.ts +++ b/src/extensions/permissions.ts @@ -4,6 +4,7 @@ * This module is storage/runtime-facing (no UI strings beyond short labels). */ +import { t } from "../language/index.js"; import { classifyExtensionSource } from "../commands/extension-source-policy.js"; import { isRecord } from "../utils/type-guards.js"; @@ -35,241 +36,91 @@ export interface StoredExtensionPermissions { downloadFile: boolean; } -const EXTENSION_CAPABILITY_DESCRIPTORS = [ - { - capability: "commands.register", - permissionKey: "commandsRegister", - label: "register commands", - }, - { - capability: "tools.register", - permissionKey: "toolsRegister", - label: "register tools", - }, - { - capability: "agent.read", - permissionKey: "agentRead", - label: "read agent state", - }, - { - capability: "agent.events.read", - permissionKey: "agentEventsRead", - label: "read agent events", - }, - { - capability: "ui.overlay", - permissionKey: "uiOverlay", - label: "show overlays", - }, - { - capability: "ui.widget", - permissionKey: "uiWidget", - label: "show widgets", - }, - { - capability: "ui.toast", - permissionKey: "uiToast", - label: "show toasts", - }, - { - capability: "llm.complete", - permissionKey: "llmComplete", - label: "call LLM completions", - }, - { - capability: "http.fetch", - permissionKey: "httpFetch", - label: "fetch external HTTP resources", - }, - { - capability: "storage.readwrite", - permissionKey: "storageReadWrite", - label: "read/write extension storage", - }, - { - capability: "connections.readwrite", - permissionKey: "connectionsReadWrite", - label: "manage connection definitions and secrets", - }, - { - capability: "connections.secrets.read", - permissionKey: "connectionsSecretsRead", - label: "read raw connection secret values", - }, - { - capability: "clipboard.write", - permissionKey: "clipboardWrite", - label: "write clipboard text", - }, - { - capability: "agent.context.write", - permissionKey: "agentContextWrite", - label: "inject agent context", - }, - { - capability: "agent.steer", - permissionKey: "agentSteer", - label: "steer active agent runs", - }, - { - capability: "agent.followup", - permissionKey: "agentFollowUp", - label: "queue agent follow-up messages", - }, - { - capability: "skills.read", - permissionKey: "skillsRead", - label: "read skill catalog", - }, - { - capability: "skills.write", - permissionKey: "skillsWrite", - label: "install/uninstall external skills", - }, - { - capability: "download.file", - permissionKey: "downloadFile", - label: "trigger file downloads", - }, -] as const satisfies ReadonlyArray<{ +interface CapabilityDescriptor { capability: string; permissionKey: keyof StoredExtensionPermissions; - label: string; -}>; + tKey: string; +} + +function getCapabilityDescriptors(): readonly CapabilityDescriptor[] { + return [ + { capability: "commands.register", permissionKey: "commandsRegister", tKey: "perm.commands.register" }, + { capability: "tools.register", permissionKey: "toolsRegister", tKey: "perm.tools.register" }, + { capability: "agent.read", permissionKey: "agentRead", tKey: "perm.agent.read" }, + { capability: "agent.events.read", permissionKey: "agentEventsRead", tKey: "perm.agent.events.read" }, + { capability: "ui.overlay", permissionKey: "uiOverlay", tKey: "perm.ui.overlay" }, + { capability: "ui.widget", permissionKey: "uiWidget", tKey: "perm.ui.widget" }, + { capability: "ui.toast", permissionKey: "uiToast", tKey: "perm.ui.toast" }, + { capability: "llm.complete", permissionKey: "llmComplete", tKey: "perm.llm.complete" }, + { capability: "http.fetch", permissionKey: "httpFetch", tKey: "perm.http.fetch" }, + { capability: "storage.readwrite", permissionKey: "storageReadWrite", tKey: "perm.storage.readwrite" }, + { capability: "connections.readwrite", permissionKey: "connectionsReadWrite", tKey: "perm.connections.readwrite" }, + { capability: "connections.secrets.read", permissionKey: "connectionsSecretsRead", tKey: "perm.connections.secrets.read" }, + { capability: "clipboard.write", permissionKey: "clipboardWrite", tKey: "perm.clipboard.write" }, + { capability: "agent.context.write", permissionKey: "agentContextWrite", tKey: "perm.agent.context.write" }, + { capability: "agent.steer", permissionKey: "agentSteer", tKey: "perm.agent.steer" }, + { capability: "agent.followup", permissionKey: "agentFollowUp", tKey: "perm.agent.followup" }, + { capability: "skills.read", permissionKey: "skillsRead", tKey: "perm.skills.read" }, + { capability: "skills.write", permissionKey: "skillsWrite", tKey: "perm.skills.write" }, + { capability: "download.file", permissionKey: "downloadFile", tKey: "perm.download.file" }, + ]; +} -export type ExtensionCapability = (typeof EXTENSION_CAPABILITY_DESCRIPTORS)[number]["capability"]; +export type ExtensionCapability = (ReturnType)[number]["capability"]; -export const ALL_EXTENSION_CAPABILITIES: ExtensionCapability[] = EXTENSION_CAPABILITY_DESCRIPTORS.map((descriptor) => { - return descriptor.capability; -}); +export const ALL_EXTENSION_CAPABILITIES: ExtensionCapability[] = [ + "commands.register", "tools.register", "agent.read", "agent.events.read", + "ui.overlay", "ui.widget", "ui.toast", "llm.complete", "http.fetch", + "storage.readwrite", "connections.readwrite", "connections.secrets.read", + "clipboard.write", "agent.context.write", "agent.steer", "agent.followup", + "skills.read", "skills.write", "download.file", +]; const TRUSTED_PERMISSIONS: StoredExtensionPermissions = { - commandsRegister: true, - toolsRegister: true, - agentRead: true, - agentEventsRead: true, - uiOverlay: true, - uiWidget: true, - uiToast: true, - llmComplete: true, - httpFetch: true, - storageReadWrite: true, - connectionsReadWrite: true, - connectionsSecretsRead: false, - clipboardWrite: true, - agentContextWrite: false, - agentSteer: false, - agentFollowUp: false, - skillsRead: true, - skillsWrite: false, - downloadFile: true, + commandsRegister: true, toolsRegister: true, agentRead: true, agentEventsRead: true, + uiOverlay: true, uiWidget: true, uiToast: true, llmComplete: true, httpFetch: true, + storageReadWrite: true, connectionsReadWrite: true, connectionsSecretsRead: false, + clipboardWrite: true, agentContextWrite: false, agentSteer: false, agentFollowUp: false, + skillsRead: true, skillsWrite: false, downloadFile: true, }; const RESTRICTED_UNTRUSTED_PERMISSIONS: StoredExtensionPermissions = { - commandsRegister: true, - toolsRegister: false, - agentRead: false, - agentEventsRead: false, - uiOverlay: true, - uiWidget: true, - uiToast: true, - llmComplete: false, - httpFetch: false, - storageReadWrite: true, - connectionsReadWrite: false, - connectionsSecretsRead: false, - clipboardWrite: true, - agentContextWrite: false, - agentSteer: false, - agentFollowUp: false, - skillsRead: true, - skillsWrite: false, - downloadFile: true, -}; - -const TRUST_LABELS: Record = { - builtin: "builtin", - "local-module": "local module", - "inline-code": "inline code", - "remote-url": "remote URL", + commandsRegister: true, toolsRegister: false, agentRead: false, agentEventsRead: false, + uiOverlay: true, uiWidget: true, uiToast: true, llmComplete: false, httpFetch: false, + storageReadWrite: true, connectionsReadWrite: false, connectionsSecretsRead: false, + clipboardWrite: true, agentContextWrite: false, agentSteer: false, agentFollowUp: false, + skillsRead: true, skillsWrite: false, downloadFile: true, }; -function getCapabilityDescriptor(capability: ExtensionCapability): (typeof EXTENSION_CAPABILITY_DESCRIPTORS)[number] { - const descriptor = EXTENSION_CAPABILITY_DESCRIPTORS.find((entry) => entry.capability === capability); +function getCapabilityDescriptor(capability: ExtensionCapability): CapabilityDescriptor { + const descriptor = getCapabilityDescriptors().find((entry) => entry.capability === capability); if (!descriptor) { throw new Error(`Unknown extension capability: ${capability}`); } - return descriptor; } function clonePermissions(source: StoredExtensionPermissions): StoredExtensionPermissions { - return { - commandsRegister: source.commandsRegister, - toolsRegister: source.toolsRegister, - agentRead: source.agentRead, - agentEventsRead: source.agentEventsRead, - uiOverlay: source.uiOverlay, - uiWidget: source.uiWidget, - uiToast: source.uiToast, - llmComplete: source.llmComplete, - httpFetch: source.httpFetch, - storageReadWrite: source.storageReadWrite, - connectionsReadWrite: source.connectionsReadWrite, - connectionsSecretsRead: source.connectionsSecretsRead, - clipboardWrite: source.clipboardWrite, - agentContextWrite: source.agentContextWrite, - agentSteer: source.agentSteer, - agentFollowUp: source.agentFollowUp, - skillsRead: source.skillsRead, - skillsWrite: source.skillsWrite, - downloadFile: source.downloadFile, - }; -} - -function normalizeBooleanOrFallback(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; + return { ...source }; } export function deriveStoredExtensionTrust(entryId: string, source: ExtensionSourceLike): StoredExtensionTrust { - if (source.kind === "inline") { - return "inline-code"; - } - + if (source.kind === "inline") return "inline-code"; const sourceKind = classifyExtensionSource(source.specifier); - if (sourceKind === "remote-url") { - return "remote-url"; - } - - if (sourceKind === "blob-url") { - return "inline-code"; - } - - if (entryId === "builtin.snake" || entryId.startsWith("builtin.")) { - return "builtin"; - } - + if (sourceKind === "remote-url") return "remote-url"; + if (sourceKind === "blob-url") return "inline-code"; + if (entryId === "builtin.snake" || entryId.startsWith("builtin.")) return "builtin"; return "local-module"; } export function getDefaultPermissionsForTrust(trust: StoredExtensionTrust): StoredExtensionPermissions { - if (trust === "builtin" || trust === "local-module") { - return clonePermissions(TRUSTED_PERMISSIONS); - } - + if (trust === "builtin" || trust === "local-module") return clonePermissions(TRUSTED_PERMISSIONS); return clonePermissions(RESTRICTED_UNTRUSTED_PERMISSIONS); } -export function normalizeStoredExtensionPermissions( - raw: unknown, - trust: StoredExtensionTrust, -): StoredExtensionPermissions { +export function normalizeStoredExtensionPermissions(raw: unknown, trust: StoredExtensionTrust): StoredExtensionPermissions { const defaults = getDefaultPermissionsForTrust(trust); - - if (!isRecord(raw)) { - return defaults; - } - + if (!isRecord(raw)) return defaults; return { commandsRegister: normalizeBooleanOrFallback(raw.commandsRegister, defaults.commandsRegister), toolsRegister: normalizeBooleanOrFallback(raw.toolsRegister, defaults.toolsRegister), @@ -293,43 +144,35 @@ export function normalizeStoredExtensionPermissions( }; } -export function isExtensionCapabilityAllowed( - permissions: StoredExtensionPermissions, - capability: ExtensionCapability, -): boolean { +export function isExtensionCapabilityAllowed(permissions: StoredExtensionPermissions, capability: ExtensionCapability): boolean { const descriptor = getCapabilityDescriptor(capability); return permissions[descriptor.permissionKey]; } -export function setExtensionCapabilityAllowed( - permissions: StoredExtensionPermissions, - capability: ExtensionCapability, - allowed: boolean, -): StoredExtensionPermissions { +export function setExtensionCapabilityAllowed(permissions: StoredExtensionPermissions, capability: ExtensionCapability, allowed: boolean): StoredExtensionPermissions { const descriptor = getCapabilityDescriptor(capability); - return { - ...permissions, - [descriptor.permissionKey]: allowed, - }; + return { ...permissions, [descriptor.permissionKey]: allowed }; } export function describeStoredExtensionTrust(trust: StoredExtensionTrust): string { - return TRUST_LABELS[trust]; + return t("perm.trust." + trust); } export function describeExtensionCapability(capability: ExtensionCapability): string { const descriptor = getCapabilityDescriptor(capability); - return descriptor.label; + return t(descriptor.tKey); } export function listAllExtensionCapabilities(): ExtensionCapability[] { return [...ALL_EXTENSION_CAPABILITIES]; } -export function listGrantedExtensionCapabilities( - permissions: StoredExtensionPermissions, -): ExtensionCapability[] { - return EXTENSION_CAPABILITY_DESCRIPTORS +export function listGrantedExtensionCapabilities(permissions: StoredExtensionPermissions): ExtensionCapability[] { + return getCapabilityDescriptors() .filter((descriptor) => permissions[descriptor.permissionKey]) .map((descriptor) => descriptor.capability); } + +function normalizeBooleanOrFallback(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} diff --git a/src/extensions/runtime-manager.ts b/src/extensions/runtime-manager.ts index dc137679..5bef7f96 100644 --- a/src/extensions/runtime-manager.ts +++ b/src/extensions/runtime-manager.ts @@ -9,6 +9,7 @@ */ import type { Agent, AgentEvent, AgentTool } from "@earendil-works/pi-agent-core"; +import { t } from "../language/index.js"; import type { ConnectionManager } from "../connections/manager.js"; import { @@ -415,7 +416,7 @@ export class ExtensionRuntimeManager { const apiKey = agent.getApiKey ? await agent.getApiKey(model.provider) : undefined; if (!apiKey) { - throw new Error(`No API key available for provider "${model.provider}".`); + throw new Error(t("runtime.error.noApiKey", { provider: model.provider })); } if (!Array.isArray(request.messages)) { diff --git a/src/files/backend.ts b/src/files/backend.ts index 53c3091d..287e1ee4 100644 --- a/src/files/backend.ts +++ b/src/files/backend.ts @@ -2,6 +2,7 @@ * Workspace storage backends. */ +import { t } from "../language/index.js"; import { base64ToBytes, bytesToBase64, decodeTextUtf8, encodeTextUtf8 } from "./encoding.js"; import { inferFileKind, inferMimeType } from "./mime.js"; import { getWorkspaceBaseName, normalizeWorkspacePath, splitWorkspacePath } from "./path.js"; @@ -228,7 +229,7 @@ async function getOpfsRoot(): Promise { export class OpfsBackend implements WorkspaceBackend { readonly kind = "opfs"; - readonly label = "Sandboxed workspace"; + readonly label = t("files-backend.sandboxedWorkspace"); async listFiles(): Promise { const root = await getOpfsRoot(); diff --git a/src/files/workspace.ts b/src/files/workspace.ts index 8cb4633f..d6ef8a5f 100644 --- a/src/files/workspace.ts +++ b/src/files/workspace.ts @@ -7,14 +7,23 @@ * 3) in-memory fallback (non-browser/test environments) */ +import { t } from "../language/index.js"; import { formatWorkbookLabel, getWorkbookContext } from "../workbook/context.js"; +import { t } from "../language/index.js"; import { isRecord } from "../utils/type-guards.js"; +import { t } from "../language/index.js"; import { base64ToBytes, bytesToBase64, encodeTextUtf8, truncateBase64, truncateText } from "./encoding.js"; +import { t } from "../language/index.js"; import { MemoryBackend, NativeDirectoryBackend, OpfsBackend, type WorkspaceBackend } from "./backend.js"; +import { t } from "../language/index.js"; import { getBuiltinWorkspaceDoc, isBuiltinWorkspacePath, listBuiltinWorkspaceDocs } from "./builtin-docs.js"; +import { t } from "../language/index.js"; import { resolveSafeBlobUrlMimeType } from "./blob-url-safety.js"; +import { t } from "../language/index.js"; import { inferMimeType, isTextMimeType } from "./mime.js"; +import { t } from "../language/index.js"; import { getWorkspaceBaseName, normalizeWorkspacePath } from "./path.js"; +import { t } from "../language/index.js"; import { FILES_WORKSPACE_CHANGED_EVENT, type FilesWorkspaceAuditAction, @@ -402,7 +411,7 @@ function backendLabel(kind: WorkspaceBackendStatus["kind"]): string { case "native-directory": return "Local folder"; case "opfs": - return "Sandboxed workspace"; + return t("files-backend.sandboxedWorkspace"); case "memory": return "Session memory"; } diff --git a/src/language/index.ts b/src/language/index.ts new file mode 100644 index 00000000..98ca7a26 --- /dev/null +++ b/src/language/index.ts @@ -0,0 +1,39 @@ +/** + * Lightweight translation function — zero dependencies. + * + * Usage: + * import { t } from "../language/index.js"; + * element.textContent = t("welcome.subtitle"); + * element.textContent = t("welcome.connected", { label: "DeepSeek" }); + */ + +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +const translations: Record> = { + en, + "zh-CN": zhCN, +}; + +let currentLang = "en"; + +export function initLanguage(lang: string): void { + if (translations[lang]) { + currentLang = lang; + } +} + +export function t(key: string, vars?: Record): string { + const dict = translations[currentLang] ?? translations.en; + let value = dict[key] ?? translations.en[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + value = value.replace(`{${k}}`, v); + } + } + return value; +} + +export function getLanguage(): string { + return currentLang; +} diff --git a/src/language/locales/en.json b/src/language/locales/en.json new file mode 100644 index 00000000..3df57a00 --- /dev/null +++ b/src/language/locales/en.json @@ -0,0 +1,911 @@ +{ + "input.attach.aria": "Browse files", + "input.chat.aria": "Chat message", + "input.send.aria": "Send", + "input.stop.aria": "Stop", + "input.drop.hint": "Drop files to import to file workspace", + "input.streaming.placeholder": "Redirect reply (↵) · New question (⌥↵)", + "input.placeholder.ask": "Ask about this workbook…", + "input.placeholder.commands": "Type / for commands…", + "input.placeholder.edit": "Have Pi edit this workbook…", + "input.placeholder.summarize": "Summarize this workbook…", + "working.default": "Working…", + "working.hint.escape": "Press Esc to stop", + "working.hint.reasoning": "⇧Tab to adjust reasoning depth", + "working.hint.commands": "Type / for commands", + "working.hint.collapse": "⌃O to collapse tool details", + "working.hint.redirect": "Press Enter to redirect Pi", + "whimsical.schlepping": "Schlepping…", + "whimsical.combobulating": "Combobulating…", + "whimsical.vibing": "Vibing…", + "whimsical.concocting": "Concocting…", + "whimsical.transmuting": "Transmuting…", + "whimsical.pontificating": "Pontificating…", + "whimsical.cogitating": "Cogitating…", + "whimsical.noodling": "Noodling…", + "whimsical.percolating": "Percolating…", + "whimsical.ruminating": "Ruminating…", + "whimsical.simmering": "Simmering…", + "whimsical.marinating": "Marinating…", + "whimsical.fermenting": "Fermenting…", + "whimsical.brewing": "Brewing…", + "whimsical.steeping": "Steeping…", + "whimsical.contemplating": "Contemplating…", + "whimsical.musing": "Musing…", + "whimsical.pondering": "Pondering…", + "whimsical.mulling": "Mulling…", + "whimsical.daydreaming": "Daydreaming…", + "whimsical.tinkering": "Tinkering…", + "whimsical.finagling": "Finagling…", + "whimsical.wrangling": "Wrangling…", + "whimsical.meandering": "Meandering…", + "whimsical.moseying": "Moseying…", + "whimsical.pottering": "Pottering…", + "whimsical.bumbling": "Bumbling…", + "whimsical.futzing": "Futzing…", + "whimsical.kerfuffling": "Kerfuffling…", + "whimsical.bamboozling": "Bamboozling…", + "whimsical.discombobulating": "Discombobulating…", + "whimsical.recombobulating": "Recombobulating…", + "whimsical.confabulating": "Confabulating…", + "whimsical.flummoxing": "Flummoxing…", + "whimsical.befuddling": "Befuddling…", + "whimsical.effervescing": "Effervescing…", + "whimsical.fizzing": "Fizzing…", + "whimsical.bubbling": "Bubbling…", + "whimsical.scintillating": "Scintillating…", + "whimsical.improvising": "Improvising…", + "whimsical.frolicking": "Frolicking…", + "whimsical.calculating": "Calculating…", + "whimsical.recalculating": "Recalculating…", + "whimsical.pivoting": "Pivoting…", + "whimsical.subtotaling": "Subtotaling…", + "whimsical.autofilling": "Autofilling…", + "whimsical.tabulating": "Tabulating…", + "whimsical.auditing": "Auditing…", + "whimsical.reconciling": "Reconciling…", + "whimsical.amortizing": "Amortizing…", + "whimsical.compounding": "Compounding…", + "whimsical.accruing": "Accruing…", + "whimsical.depreciating": "Depreciating…", + "whimsical.forecasting": "Forecasting…", + "whimsical.extrapolating": "Extrapolating…", + "whimsical.interpolating": "Interpolating…", + "whimsical.consulting_void": "Consulting the void…", + "whimsical.asking_electrons": "Asking electrons…", + "whimsical.negotiating_entropy": "Negotiating with entropy…", + "whimsical.waxing_philosophical": "Waxing philosophical…", + "whimsical.reading_tea_leaves": "Reading tea leaves…", + "whimsical.magic_8ball": "Shaking the magic 8-ball…", + "whimsical.warming_hamsters": "Warming up the hamsters…", + "whimsical.little_think": "Having a little think…", + "whimsical.stroking_chin": "Thoughtfully stroking chin…", + "whimsical.squinting": "Squinting at the problem…", + "whimsical.staring_abyss": "Staring into the abyss…", + "whimsical.abyss_staring_back": "The abyss stares back…", + "whimsical.enlightenment": "Seeking enlightenment…", + "whimsical.consulting_oracle": "Consulting the oracle…", + "whimsical.reticulating_splines": "Reticulating splines…", + "whimsical.flux_capacitor": "Calibrating flux capacitor…", + "whimsical.hoping": "Hoping for the best…", + "whimsical.manifesting": "Manifesting a solution…", + "whimsical.willing": "Willing it into existence…", + "whimsical.believing": "Believing really hard…", + "whimsical.reading_room": "Reading the room…", + "whimsical.kicking_tires": "Kicking the tires…", + "whimsical.dusting_neurons": "Dusting off neurons…", + "whimsical.rearranging_deck_chairs": "Rearranging deck chairs…", + "whimsical.circular_reference": "Appeasing circular references…", + "whimsical.bribing_formula_bar": "Bribing the formula bar…", + "whimsical.rounding_errors": "Reasoning with rounding errors…", + "whimsical.print_preview": "Begging print preview…", + "whimsical.herding_cells": "Herding cells into alignment…", + "whimsical.wrestling_arrays": "Wrestling with array formulas…", + "whimsical.taming_ref": "Taming wild #REF! errors…", + "whimsical.missing_penny": "Looking for the missing penny…", + "whimsical.spreadsheet_gods": "Consulting the spreadsheet gods…", + "whimsical.reticulating_spreadsheets": "Reticulating spreadsheets…", + "whimsical.massaging_margins": "Massaging margins…", + "whimsical.merged_cells": "Arguing with merged cells…", + "whimsical.conditional_formatting": "Flirting with conditional formatting…", + "whimsical.column_widths": "Negotiating column widths…", + "whimsical.index_match": "Politely requesting INDEX MATCH…", + "whimsical.befriending_ribbon": "Befriending the ribbon…", + "whimsical.tiptoeing_macros": "Tiptoeing around macros…", + "whimsical.convincing_cells": "Convincing cells to cooperate…", + "whimsical.data_validation": "Feeding data validation…", + "whimsical.whatif_analysis": "Warming up what-if analysis…", + "whimsical.cross_referencing": "Cross-referencing worksheets…", + "whimsical.auditing_formula_trail": "Auditing the formula trail…", + "whimsical.tracing_precedents": "Tracing precedents…", + "whimsical.evaluating_dependents": "Evaluating dependents…", + "whimsical.freezing_panes": "Thoughtfully freezing panes…", + "whimsical.persuading_offset": "Persuading OFFSET to cooperate…", + "whimsical.checking_indirect": "Checking inside INDIRECT…", + "whimsical.balancing_books": "Balancing books…", + "whimsical.crunching_numbers": "Crunching numbers…", + "whimsical.counting_beans": "Counting beans…", + "whimsical.discounting_cash_flows": "Discounting future cash flows…", + "whimsical.adjusting_seasonality": "Adjusting for seasonality…", + "whimsical.monte_carlo": "Running Monte Carlo simulations…", + "whimsical.stress_testing": "Stress testing the model…", + "whimsical.sanity_checking": "Sanity checking the totals…", + "whimsical.reconciling_penny": "Reconciling to the penny…", + "whimsical.marking_to_market": "Marking to market…", + "whimsical.rolling_forecast": "Rolling forecast…", + "whimsical.building_bridge": "Building a bridge…", + "whimsical.waterfalling_revenue": "Waterfalling revenue…", + "whimsical.sensitizing": "Sensitizing assumptions…", + "whimsical.triangulating": "Triangulating the valuation…", + "whimsical.normalizing_ebitda": "Normalizing EBITDA…", + "whimsical.checking_foot": "Checking footnotes…", + "whimsical.tying_balance_sheet": "Tying the balance sheet…", + "whimsical.hardcoding_overrides": "Hardcoding override values…", + "whimsical.midyear_convention": "Forgetting mid-year convention…", + "confirm.cancel": "Cancel", + "confirm.confirm": "Confirm", + "confirm.error.unavailable": "Confirm dialog is not available in the current environment.", + "textinput.cancel": "Cancel", + "textinput.save": "Save", + "textinput.error.unavailable": "Text input dialog is not available in the current environment.", + "loading.initializing": "Initializing…", + "sidebar.empty.tagline": "Understand and operate Excel. Remember your preferences. Build your own tools.", + "sidebar.empty.hint.title": "Inserts into input — editable before sending.", + "sidebar.tabs.scroll_left": "Scroll tabs left", + "sidebar.tabs.scroll_right": "Scroll tabs right", + "sidebar.tabs.new": "New tab", + "sidebar.tabs.open": "Open tab {title}", + "sidebar.tabs.close": "Close tab", + "sidebar.tabs.close.wait": "Wait for workbook changes to complete", + "sidebar.tabs.lock": "Locking…", + "sidebar.utilities.aria": "Settings and tools", + "sidebar.menu.setup": "Setup", + "sidebar.menu.extensions": "Extensions", + "sidebar.menu.files": "Files", + "sidebar.menu.rules": "Rules", + "sidebar.menu.resume": "Resume session", + "sidebar.menu.backups": "Backups", + "sidebar.menu.keyboard_shortcuts": "Keyboard shortcuts", + "sidebar.contextmenu.rename": "Rename tab…", + "sidebar.contextmenu.duplicate": "Duplicate tab", + "sidebar.contextmenu.move_left": "Move left", + "sidebar.contextmenu.move_right": "Move right", + "sidebar.contextmenu.close_others": "Close other tabs", + "sidebar.contextmenu.close": "Close tab", + "sidebar.contextmenu.aria": "Actions for tab {title}", + "sidebar.context_pill.no_calls": "Context · no calls yet for this session", + "sidebar.context_pill.no_snapshots": "No payload snapshots for this session yet.", + "sidebar.context_pill.hint": "Send a prompt in this tab to capture call-level context details.", + "sidebar.context_pill.recent_calls": "Recent calls", + "sidebar.context_pill.tools": "Tools", + "sidebar.context_pill.copy_json": "Copy JSON", + "sidebar.context_pill.system_prompt": "System prompt", + "sidebar.context_pill.stripped": "Stripped", + "sidebar.context_pill.first": "First", + "sidebar.context_pill.continuation": "Continuation", + "sidebar.context_pill.stable": "Stable", + "sidebar.context_pill.no": "No (stable)", + "sidebar.context_pill.yes": "Yes ({reasons})", + "welcome.title": "Pi for Excel", + "welcome.subtitle": "Connect an AI provider to get started", + "welcome.intro": "An AI agent that reads your spreadsheet, makes changes, and does the research — using models you already have.", + "welcome.select_provider": "Select provider", + "welcome.custom_gateway": "Use a custom OpenAI-compatible gateway", + "welcome.proxy.toggle_show": "Having trouble logging in? Configure a local proxy", + "welcome.proxy.toggle_hide": "Hide proxy settings", + "welcome.proxy.title": "Local HTTPS proxy", + "welcome.proxy.enabled": "Enabled", + "welcome.proxy.save": "Save", + "welcome.proxy.guide": "Step-by-step guide", + "welcome.proxy.hint.prefix": "Only needed if OAuth login is blocked by CORS. Keep this URL as ", + "welcome.proxy.hint.suffix": ", run a local HTTPS proxy, then enable this toggle.", + "welcome.proxy.hint.end": ".", + "welcome.toast.cannot_open_settings": "Cannot open custom gateway settings", + "welcome.toast.proxy_saved": "Proxy settings saved", + "welcome.toast.proxy_failed": "Failed to save proxy settings", + "welcome.toast.connected": "{label} connected — try \"Explain this workbook\"", + "welcome.toast.disconnected": "{label} disconnected", + "provider.proxy_gate.title": "One more step before logging in", + "provider.proxy_gate.message": "This login requires a small helper running on your Mac. Open Terminal app and paste this command:", + "provider.proxy_gate.copy": "Copy", + "provider.proxy_gate.copied": "Copied!", + "provider.proxy_gate.hint": "Wait for \"Proxy listening\" to appear in the terminal, then click retry.", + "provider.proxy_gate.guide": "Step-by-step guide →", + "provider.proxy_gate.cancel": "Cancel", + "provider.proxy_gate.retry": "Retry", + "provider.proxy_gate.checking": "Checking…", + "provider.proxy_gate.not_detected": "Helper not detected yet — make sure \"Proxy listening\" shows in terminal, then retry.", + "provider.prompt.cancel": "Cancel", + "provider.prompt.continue": "Continue", + "provider.prompt.cancelled": "Prompt cancelled", + "provider.error.empty_key": "API key is empty", + "provider.error.oauth_code_as_key": "This looks like an OAuth authorization code (code#state). Use \"Login with Anthropic\" and paste it when prompted (don't save as API key).", + "provider.error.oauth_url_codex": "This looks like an OAuth redirect URL/code. Use \"Login with OpenAI (ChatGPT)\" and paste it in the login prompt (don't save as API key).", + "provider.error.oauth_url_google": "This looks like an OAuth redirect URL/code. Use \"Login with Google …\" and paste it in the login prompt (don't save as API key).", + "provider.error.oauth_url_google_api": "This looks like an OAuth redirect URL/code. Use a Google API key here, or the dedicated Google OAuth login row.", + "provider.connected": "✓ Connected", + "provider.set_up": "Set up →", + "provider.disconnect": "Disconnect {label}", + "provider.login_with": "Login with {label}", + "provider.or_api_key": "or enter API key", + "provider.save": "Save", + "provider.opening_login": "Opening login…", + "provider.testing": "Testing…", + "provider.login_failed": "Login failed", + "provider.save_failed": "Save key failed", + "provider.save_failed_msg": "Save key failed: {msg}", + "provider.disconnect_failed": "Disconnect failed", + "provider.disconnect_failed_msg": "Disconnect failed: {msg}", + "provider.disconnecting": "Disconnecting…", + "provider.cors_error": "Login can't connect — this provider needs a helper on your Mac. Open Terminal and run:", + "provider.cors_error.retry": ", then retry.", + "provider.oauth.helper.anthropic": "After login, the browser may show an unreachable localhost page — this is normal. Copy the full URL from the browser address bar and paste it here.", + "provider.oauth.helper.openai": "After login, the browser will show an \"unreachable\" page — this is normal! Copy the full URL from the browser address bar and paste it here.", + "provider.oauth.helper.google": "After login, the browser will show an \"unreachable\" page — this is normal! Copy the full URL from the browser address bar and paste it here.", + "provider.placeholder.anthropic": "sk-ant-api… or sk-ant-oat…", + "provider.placeholder.chatgpt": "ChatGPT OAuth access token", + "provider.placeholder.google_oauth": "Google OAuth credential JSON", + "provider.placeholder.api_key": "Enter API key", + "provider.name.anthropic": "Anthropic", + "provider.name.openai_chatgpt": "OpenAI (ChatGPT)", + "provider.name.google_code_assist": "Google Code Assist", + "provider.name.google_antigravity": "Google Antigravity", + "provider.name.github_copilot": "GitHub Copilot", + "provider.name.openai_api": "OpenAI (API)", + "provider.name.google_api": "Google Gemini (API)", + "provider.name.deepseek": "DeepSeek", + "provider.name.bedrock": "Amazon Bedrock", + "provider.name.mistral": "Mistral", + "provider.name.groq": "Groq", + "provider.name.xai": "xAI / Grok", + "provider.desc.claude": "Claude Pro/Max", + "provider.desc.openai_sub": "Plus/Pro subscription", + "provider.desc.gemini_account": "Gemini via Google account", + "provider.desc.antigravity": "Gemini/Claude/GPT-OSS", + "provider.desc.api_key": "API key", + "status.no_session": "No active session", + "status.select_model": "Select model", + "status.thinking.off": "Off", + "status.thinking.min": "Min", + "status.thinking.low": "Low", + "status.thinking.medium": "Medium", + "status.thinking.high": "High", + "status.thinking.max": "Max", + "status.thinking.flash": "Thinking depth: {level} (takes effect next turn)", + "status.thinking.tooltip": "How deeply Pi reasons before answering — higher is slower but more thorough. Click to select, or press ⇧Tab to cycle.", + "status.mode.auto": "Auto", + "status.mode.confirm": "Confirm", + "status.mode.auto.tooltip": "Auto: Pi applies workbook changes immediately. Click to switch to Confirm.", + "status.mode.confirm.tooltip": "Confirm: Pi asks before each workbook change. Click to switch to Auto.", + "status.model.tooltip": "Switch the AI model for this session.", + "status.lock.waiting": "Workbook writes are queued behind another session.", + "status.lock.active": "This session currently holds the workbook write lock.", + "status.lock.label": "Lock", + "status.lock.waiting_label": "Locking…", + "session.tab.default": "Chat {number}", + "settings.title": "Settings", + "settings.subtitle": "Providers, proxy, and preferences", + "settings.close": "Close settings", + "settings.tabs.aria": "Settings tabs", + "settings.tab.providers": "Providers", + "settings.tab.more": "More", + "settings.section.providers": "Providers", + "settings.section.providers.hint": "Connect providers to use their models.", + "settings.section.proxy": "Proxy", + "settings.section.proxy.label": "Proxy", + "settings.section.proxy.sublabel": "Route API calls through a local proxy", + "settings.section.proxy.url": "URL", + "settings.section.proxy.disabled": "Proxy is disabled.", + "settings.section.proxy.connected": "Proxy connected to {url}", + "settings.section.proxy.not_reachable": "Proxy is enabled at {url} but is not currently reachable.", + "settings.section.proxy.checking": "Checking proxy {url}…", + "settings.section.proxy.recommended": "Recommended URL:", + "settings.section.proxy.keep_localhost": ". Keep it at localhost.", + "settings.section.proxy.guide": "Installation and setup guide", + "settings.section.execution_mode": "Execution mode", + "settings.section.language.label": "Language / 语言", + "settings.section.language.en": "English", + "settings.section.execution.auto_mode": "Auto mode", + "settings.section.execution.auto_sublabel": "Pi applies changes immediately without asking", + "settings.section.execution.confirm_hint": "When off, Pi asks before each change (confirm mode).", + "settings.section.advanced": "Advanced", + "settings.section.advanced.hint": "Quick access to rules, backups, and keyboard shortcuts for power users.", + "settings.section.advanced.fork_label": "Fork to new tab on model switch", + "settings.section.advanced.fork_sublabel": "For non-empty chats, open a forked tab instead of switching in-place.", + "settings.section.advanced.fork_default": "Default: switch in-place (compatible with pi-mono).", + "settings.section.experimental": "Experimental features", + "settings.section.experimental.hint": "Advanced and in-development features.", + "settings.button.rules": "Rules & conventions…", + "settings.button.backups": "Backups…", + "settings.button.shortcuts": "Keyboard shortcuts…", + "settings.toast.connected": "{label} connected", + "settings.toast.disconnected": "{label} disconnected", + "settings.toast.proxy_save_failed": "Failed to save proxy settings.", + "settings.toast.proxy_enabled": "Proxy enabled.", + "settings.toast.proxy_disabled": "Proxy disabled.", + "settings.toast.proxy_url_invalid": "Invalid proxy URL.", + "settings.toast.proxy_url_not_saved": "Proxy URL not saved: {error}", + "settings.toast.proxy_url_save_failed": "Failed to save proxy URL.", + "settings.toast.proxy_url_saved": "Proxy URL saved.", + "settings.toast.auto_mode": "Auto mode.", + "settings.toast.confirm_mode": "Confirm mode.", + "settings.toast.execution_failed": "Couldn't update execution mode.", + "settings.toast.fork_on": "Model switching will open a new tab for non-empty chats.", + "settings.toast.fork_off": "Model switching will stay in the current tab.", + "settings.toast.fork_failed": "Couldn't update model switch setting.", + "settings.warning.provider_state": "Saved provider state is temporarily unavailable. You can still connect providers.", + "rules.title": "Rules", + "rules.subtitle": "Set guidance for all files, this workbook, and format conventions.", + "rules.close": "Close rules", + "rules.tab.all_files": "All my files", + "rules.tab.this_file": "This file", + "rules.tab.formats": "Formats", + "rules.cancel": "Cancel", + "rules.save": "Save", + "rules.workbook_tag": "Workbook: {label}", + "rules.counter": "{chars} / {limit} characters", + "rules.placeholder.user": "Your preferences and habits, e.g.\n• Always use EUR for currencies\n• Format dates as dd-mmm-yyyy\n• Check circular references after writes", + "rules.placeholder.workbook": "Notes about this workbook's structure, e.g.\n• DCF model for Acme Corp, FY2025\n• Revenue assumptions in Inputs!B5:B15\n• Don't modify the Summary sheet", + "rules.hint.user": "Guidance for Pi across all conversations. Pi can also update these when you tell it your preferences — e.g. \"Always use EUR\".", + "rules.hint.workbook.no_id": "Can't identify this workbook right now — try saving the file first.", + "rules.hint.workbook": "Guidance only given when Pi reads this file.", + "rules.hint.conventions": "Configure preset formats, font colors, header styles, and default fonts.", + "rules.section.number_formats": "Number formats", + "rules.section.colors": "Colors (font color)", + "rules.section.header_style": "Header style", + "rules.section.default_font": "Default font", + "rules.field.name": "Name", + "rules.field.description": "Description", + "rules.field.format": "Format", + "rules.field.positive": "Positive", + "rules.field.negative": "Negative", + "rules.field.zero": "Zero", + "rules.field.hardcoded_values": "Hardcoded values", + "rules.field.cross_sheet_links": "Cross-sheet links", + "rules.field.fill": "Fill", + "rules.field.font": "Font", + "rules.field.fill_color": "Fill color", + "rules.field.font_color": "Font color", + "rules.field.bold": "Bold", + "rules.field.wrap_text": "Wrap text", + "rules.field.font_name": "Font name", + "rules.field.font_size": "Font size", + "rules.field.dp": "Decimal places", + "rules.field.neg": "Negative", + "rules.field.zero_style": "Zero", + "rules.field.symbol": "Symbol", + "rules.toggle.on": "On", + "rules.toggle.off": "Off", + "rules.preset.blank": "Blank", + "rules.preset.custom": "Custom", + "rules.preset.blank_zero": "(blank)", + "rules.preset.negative.parens": "(1,234)", + "rules.preset.negative.minus": "-1,234", + "rules.preset.zero.dash": "--", + "rules.preset.zero.single_dash": "-", + "rules.preset.zero.zero": "0", + "rules.placeholder.optional": "Optional", + "rules.placeholder.color": "#RRGGBB or rgb(r,g,b)", + "rules.placeholder.custom_format_reset": "Custom format — use quick options to reset", + "rules.button.add_custom": "Add custom format", + "rules.button.remove_preset": "Remove preset", + "rules.header_preview.aria": "Header style preview", + "rules.toast.saved": "Rules saved", + "shortcuts.title": "Keyboard shortcuts", + "shortcuts.subtitle": "Quick reference for chat, tab, navigation, and system shortcuts.", + "shortcuts.close": "Close keyboard shortcuts", + "shortcuts.group.chat": "Chat", + "shortcuts.group.tabs": "Tabs", + "shortcuts.group.navigation": "Navigation", + "shortcuts.group.system": "System", + "shortcuts.desc.send": "Send message", + "shortcuts.desc.interrupt": "Interrupt and redirect", + "shortcuts.desc.queue": "Queue follow-up message", + "shortcuts.desc.restore_queue": "Restore queued message", + "shortcuts.desc.cycle_thinking": "Cycle thinking depth", + "shortcuts.desc.new_tab": "New tab", + "shortcuts.desc.close_tab": "Close tab", + "shortcuts.desc.reopen_tab": "Reopen closed tab", + "shortcuts.desc.switch_tabs": "Switch tabs (exit input first)", + "shortcuts.desc.prev_next_tab": "Previous / next tab", + "shortcuts.desc.command_menu": "Open command menu", + "shortcuts.desc.navigate_menu": "Navigate menu items", + "shortcuts.desc.focus_input": "Focus chat input", + "shortcuts.desc.toggle_focus": "Toggle focus: worksheet ↔ sidebar", + "shortcuts.desc.toggle_focus_reverse": "Toggle focus (reverse)", + "shortcuts.desc.dismiss": "Dismiss popup / Stop generation / Exit input", + "command.model.desc": "Switch AI model", + "command.default_models.desc": "Switch models with Ctrl+P", + "command.model.no_session": "No active session", + "command.settings.desc": "Settings (providers and advanced options)", + "command.login.desc": "Open provider settings", + "command.yolo.desc": "Toggle execution mode (auto vs confirm)", + "command.rules.desc": "Edit Pi's rules (all files + current file)", + "command.yolo.mode.auto": "{mode} mode — Pi applies workbook changes immediately.", + "command.yolo.mode.safe": "{mode} mode — Pi asks before each workbook change.", + "command.name.desc": "Name the current chat session", + "command.share_session.desc": "Share session as a link", + "command.new.desc": "New chat session tab", + "command.resume.desc": "Resume previous session (opens in new tab)", + "command.resume_here.desc": "Resume previous session in current tab", + "command.history.desc": "Open backups", + "command.backup.desc": "Manual full-workbook backup (create/list/restore/clear)", + "command.reopen.desc": "Reopen most recently closed session tab", + "command.revert.desc": "Revert the latest workbook backup", + "session.toast.name_usage": "Usage: /name My Session Name", + "session.toast.named": "Session named: {title}", + "session.toast.sharing_soon": "Session sharing coming soon", + "session.toast.backup_created": "Manual backup created: #{id} ({size}).", + "session.toast.no_backups": "No manual full-workbook backups for this workbook.", + "session.toast.backups_list": "Manual backups (showing {count}): {preview}", + "session.toast.backup_not_found": "Could not find backups for this workbook.", + "session.toast.backup_restored": "Downloaded backup #{id}. Open the file in Excel to restore.", + "session.toast.backups_deleted": "Deleted {count} manual backups.", + "session.toast.backup_failed": "Backup command failed: {message}", + "session.toast.backup_usage": "Usage: /backup [create|list [limit]|restore [id]|clear]", + "session.confirm.clear_title": "Delete all manual full-workbook backups?", + "session.confirm.clear_message": "This will delete all manual full-workbook backups for the active workbook.", + "session.confirm.clear_label": "Delete all", + "session.confirm.cancel": "Cancel", + "session.backup.unknown_error": "Unknown error", + "export.command.desc": "Export JSON (session transcript or audit log)", + "export.toast.no_session": "No active session", + "export.toast.no_messages": "No messages to export", + "export.toast.transcript_copied": "Transcript copied ({count} messages, {size}KB)", + "export.toast.copy_failed": "Copy failed: {error}", + "export.toast.transcript_downloaded": "Transcript downloaded ({count} messages)", + "export.toast.audit_export_failed": "Audit export failed: {error}", + "export.toast.audit_copied": "Audit log copied ({count} records, {size}KB)", + "export.toast.audit_downloaded": "Audit log downloaded ({count} records)", + "export.toast.compact.few_messages": "Too few messages to compact", + "export.toast.compact.compacting": "Compacting to free context", + "export.toast.compact.no_model": "No model configured for compacting", + "export.toast.compact.no_api_key": "No API key for {provider}. Use /login or /settings.", + "export.toast.compact.memory_nudge": "Compact reminder: found {count} memories{cue} in old messages. If needed, save persistent info to notes (rules via instructions).", + "export.toast.compact.retrying": "Compact input too large — retrying with stronger truncation", + "export.toast.compact.summarized": "Summarized {count} messages", + "export.toast.compact.nothing": "Nothing to compact", + "export.toast.compact.failed": "Compact failed: {msg}", + "export.toast.compact.failed_error": "Compact failed", + "export.toast.compact.summary_unavailable": "Summary unavailable", + "export.compact.prompt": "You are a context summarizer assistant. Your task is to read a conversation between a user and an AI assistant, then generate a structured summary in the precise format specified.\n\nDo not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary.", + "command.plugins.desc": "Manage installed plugins (alias for /extensions plugins)", + "bootstrap.fatalError": "Initialization failed: {msg}", + "bootstrap.fatalTimeout": "Initialization failed: taskpane timeout", + "bootstrap.initTimeoutWarning": "Taskpane initialization taking longer than expected (>12s)", + "bootstrap.officeUnavailable": "Office.js unavailable", + "bridge-setup.bridgeDetected": "✓ Bridge detected — ask the assistant to try again.", + "bridge-setup.bridgeNotDetected": "Bridge not detected yet — keep terminal open and retry.", + "bridge-setup.checking": "Checking…", + "bridge-setup.dismiss": "Dismiss", + "bridge-setup.intro": "Run in terminal:", + "bridge-setup.pythonUnavailable": "Python unavailable", + "bridge-setup.testConnection": "Test connection", + "bridge-setup.tmuxTitle": "Terminal access unavailable", + "clipboard.commandDescription": "Copy the last assistant message to clipboard", + "clipboard.commandName": "copy", + "clipboard.copied": "Copied to clipboard", + "clipboard.noAgentMessage": "No assistant message to copy", + "custom-gateway.cancelButton": "Cancel", + "custom-gateway.deleteButton": "Delete", + "custom-gateway.deleteConfirmMsg": "Delete gateway \"{name}\"? This removes its stored API key from this add-in.", + "custom-gateway.deleteConfirmTitle": "Delete custom gateway?", + "custom-gateway.editButton": "Edit", + "custom-gateway.endpointPlaceholder": "https://your-gateway.example.com/v1", + "custom-gateway.hint": "For corporate LLM gateways or local OpenAI-compatible servers.", + "custom-gateway.modelPlaceholder": "model-id", + "custom-gateway.namePlaceholder": "Gateway name (optional)", + "custom-gateway.noGateways": "No custom gateways configured yet.", + "custom-gateway.saveGateway": "Save gateway", + "custom-gateway.title": "Custom OpenAI-compatible gateway", + "disclosure-bar.customize": "Customize", + "disclosure-bar.done": "Done", + "disclosure-bar.externalServicesLabel": "External services (MCP)", + "disclosure-bar.extensionsLabel": "Extensions & plugins", + "disclosure-bar.gotIt": "Got it", + "disclosure-bar.skillsLabel": "Skills", + "disclosure-bar.text": "Pi can search the web, use extensions, and connect to external services.", + "disclosure-bar.webSearchLabel": "Web search & page fetch", + "experimental.advancedSecurity": "Advanced / Security controls", + "experimental.feature.none": "(none)", + "experimental.legacy_redirect": "External tools (including MCP) are managed in /tools, not /experimental.", + "experimental.usage.tmux_status": "Usage: /experimental tmux-status", + "experimental.disabled": "Disabled", + "experimental.enabled": "Enabled", + "experimental.hint": "Features in development, may evolve rapidly.", + "experimental.noFeatures": "No experimental features currently available.", + "experimental.notWired": "Currently flag-only", + "experimental.ready": "Ready", + "experimental.title": "Experimental features", + "extensions-hub-connections.addServer": "+ Add server", + "extensions-hub-connections.apiKeyLabel": "API key", + "extensions-hub-connections.bridgesSection": "Bridges", + "extensions-hub-connections.clearButton": "Clear", + "extensions-hub-connections.externalTools": "External tools", + "extensions-hub-connections.mcpSection": "MCP Servers", + "extensions-hub-connections.providerLabel": "Provider", + "extensions-hub-connections.saveButton": "Save", + "extensions-hub-connections.validateButton": "Validate", + "extensions-hub-connections.webSearch": "Web search", + "extensions-hub-connections.webSearchConnected": "Connected", + "extensions-hub-connections.webSearchOff": "Off", + "extensions-hub-skills.bundledSection": "Bundled skills", + "extensions-hub-skills.externalSection": "External skills", + "extensions-hub-skills.installButton": "Install skill", + "extensions-hub-skills.installPlaceholder": "Instructions for the agent...", + "extensions-hub-skills.installSection": "Install skill", + "extensions-hub-skills.bundledBadge": "Bundled", + "extensions-hub-skills.externalBadge": "External", + "extensions-hub-skills.statusLine": "{active} skills active", + "extensions-hub.closeLabel": "Close extensions", + "extensions-hub.subtitle": "Extend Pi with connections, plugins, and skills", + "extensions-hub.tabConnections": "Connections", + "extensions-hub.tabPlugins": "Plugins", + "extensions-hub.tabSkills": "Skills", + "extensions-hub.title": "Extensions", + "manifest.ButtonDesc": "Open Pi AI assistant sidebar", + "manifest.ButtonLabel": "Open Pi", + "manifest.Description": "Open-source, multi-model Excel sidebar AI add-in. Powered by Pi.", + "message-renderers.archived": "Archived history (UI only)", + "message-renderers.noArchived": "(No archived messages)", + "message-renderers.noSummary": "(No summary)", + "message-renderers.showEarlier": "Show earlier messages", + "message-renderers.summarized": "Summarized {count} messages", + "message-renderers.summaryLabel": "Summary", + "proxy-banner.action": "How to fix →", + "proxy-banner.copied": "Copied", + "proxy-banner.copyCommand": "Copy command", + "proxy-banner.guideLink": "No Node.js? See install guide →", + "proxy-banner.hint": "Open Terminal · paste · press Enter · keep open", + "proxy-banner.intro": "Run this command in your terminal and keep the window open:", + "proxy-banner.warning": "Proxy not running · Some features won't work.", + "queue-display.followUpLabel": "Follow up", + "queue-display.steerLabel": "Steer", + "recovery.backupNotFound": "Backup not found", + "recovery.backupsSuffix": " backups", + "recovery.capturing": "Capturing…", + "recovery.clearAll": "Clear all", + "recovery.delete": "Delete", + "recovery.downloadBackup": "Download backup", + "recovery.emptyFilter": "No backups match the current filter.", + "recovery.emptyState": "No backups yet\nPi will save snapshots here before making changes to your data.", + "recovery.keepAtMost": "Keep at most", + "recovery.loading": "Loading…", + "recovery.refresh": "Refresh", + "recovery.refreshing": "Refreshing…", + "recovery.restore": "Restore", + "recovery.retentionSave": "Save", + "recovery.retentionSettings": "Retention settings", + "recovery.searchPlaceholder": "Search backups…", + "recovery.sortNewest": "↓ Newest", + "recovery.sortOldest": "↑ Oldest", + "recovery.subtitle": "Snapshots saved before Pi modifies data", + "recovery.title": "Backups", + "recovery.warning": "Backups will be cleared when this workbook is saved in Excel.", + "resume.closeLabel": "Close resume session", + "resume.sessionNotInRecentlyClosed": "Session is no longer in recently closed", + "resume.sessionNotFound": "Session not found", + "resume.defaultTitle": "Untitled", + "resume.noPreviousSessions": "No previous sessions", + "resume.noSessionsForWorkbook": "No available sessions for this workbook.", + "resume.openInNewTab": "Open in new tab", + "resume.recentlyClosed": "Recently closed", + "resume.recentlyClosedHint": "Reopen recently closed tabs directly in a new tab.", + "resume.replaceCurrent": "Replace current tab", + "resume.savedSessions": "Saved sessions", + "resume.showAllWorkbooks": "Show sessions from all workbooks", + "resume.subtitle": "Select a session to resume in a new tab or current tab.", + "resume.title": "Resume session", + "taskpane.title": "Pi for Excel", + "web-search-setup.checking": "Checking…", + "web-search-setup.dismiss": "Dismiss", + "web-search-setup.helperDetected": "✓ Helper detected", + "web-search-setup.retry": "Retry", + "web-search-setup.save": "Save", + "web-search-setup.startHelper": "Start the helper (keep running):", + "web-search-setup.tryAgain": "Please ask the assistant to try again.", + "web-search-setup.validating": "Validating…", + "bridge-setup.copiedTitle": "Copied", + "bridge-setup.copyCommandTitle": "Copy command", + "command.debug.toggle": "Toggle debug UI (usage breakdown, extra diagnostics)", + "command.experimental.manage": "Manage experimental features", + "command.export.json": "Export JSON (session transcript or audit log)", + "command.export.summarize": "Summarize older messages to free context", + "command.model.cycle": "Switch models with Ctrl+P", + "command.session.backup": "Manual full-workbook backup", + "command.session.backups": "Open backups", + "command.session.name": "Name the current chat session", + "command.session.new": "New chat session tab", + "command.session.reopen": "Reopen the most recently closed session tab", + "command.session.resume": "Resume a previous session", + "command.session.revert": "Revert the latest workbook backup", + "command.session.share": "Share session as a link", + "command.settings.mode": "Toggle execution mode (auto vs confirm)", + "command.settings.providers": "Open provider settings", + "command.settings.rules": "Edit Pi's rules", + "export.compact.desc": "Summarize old messages to free context", + "queue-display.shortcutHint": "↳ Press {shortcut} to edit queued message", + "recovery.clear": "Clear all", + "recovery.close": "Close backups", + "rules.preset.currency": "Currency", + "rules.preset.integer": "Integer", + "rules.preset.number": "Number", + "rules.preset.percent": "Percent", + "rules.preset.ratio": "Ratio", + "rules.preset.text": "Text", + "web-search-setup.stepLabel": "Step {n} · Start the helper (keep running):", + "app.title": "Pi for Excel", + "command.clipboard.no_session": "No active session", + "command.debug.enabled": "Debug enabled", + "command.debug.disabled": "Debug disabled", + "command.debug.is_enabled": "Debug is enabled", + "command.debug.is_disabled": "Debug is disabled", + "command.debug.usage": "Usage: /debug [on|off|toggle|status]", + "files-dialog.toast.fileNotAvailable": "That file is no longer available.", + "web-search-setup.toast.enterApiKey": "Enter an API key first.", + "web-search-setup.toast.ready": "✓ Web search is ready — ask the assistant to try again.", + "keyboard-shortcuts.toast.noQueuedMessages": "No queued messages to restore", + "editor-actions.toast.noActiveSession": "No active session", + "extensions-hub-plugins.toast.enterUrl": "Enter a URL first.", + "extensions-hub-skills.toast.pasteSkillMd": "Paste a SKILL.md document first.", + "extensions-hub-connections.toast.noApiKeyToValidate": "No API key to validate.", + "extensions-hub-connections.toast.enterApiKey": "Enter an API key first.", + "extensions-hub-connections.toast.noActiveSession": "No active session", + "extensions-hub-connections.toast.workbookScopeUnavailable": "Workbook scope unavailable", + "extensions-hub-extension-connections.toast.enterAtLeastOneField": "Enter at least one field to save.", + "init.securityWarning": "Security warning: proxy URL is not localhost — it can see your tokens and prompts.", + "init.noActiveSession": "No active session", + "init.currentTabBusy": "Current tab is busy — use open in new tab or wait for it to finish", + "init.couldNotReopenSession": "Couldn't reopen session", + "init.sessionNotInRecentlyClosed": "Session is no longer in recently closed", + "init.noRecentlyClosedTab": "No recently closed tab", + "init.noBackupsYet": "No backups for this workbook yet", + "init.cantCloseLastTab": "Can't close the last tab", + "init.waitForChangesBeforeClose": "Wait for workbook changes to finish before closing this tab", + "init.sessionNotFound": "Session not found", + "init.waitBeforeRenaming": "Wait for this tab to finish before renaming", + "init.tabNameReset": "Tab name reset", + "init.waitBeforeDuplicating": "Wait for this tab to finish before duplicating", + "init.noOtherTabs": "No other tabs", + "init.noTabsWereClosed": "No tabs were closed", + "init.waitBeforeChangingModels": "Wait for this tab to finish before changing models", + "init.noFilesImported": "No files were imported.", + "settings.lang.reloading": "Language changed. Reloading...", + "settings.lang.saveFailed": "Failed to save language setting.", + "resume.couldNotReopen": "Couldn't reopen session", + "rules-overlay.cost-of-goods": "Cost of Goods", + "rules-overlay.sold": "Sold", + "rules-overlay.cost-of-goods-ellipsis": "Cost of Goods…", + "rules-overlay.custom-format-tip": "Custom format — use quick options to reset", + "rules-overlay.remove-preset": "Remove preset", + "rules-overlay.number-formats": "Number formats", + "rules-overlay.add-custom-format": "Add custom format", + "rules-overlay.colors-font-color": "Colors (font color)", + "rules-overlay.header-style": "Header style", + "rules-overlay.default-font": "Default font", + "rules-overlay.all-my-files": "All my files", + "rules-overlay.this-file": "This file", + "rules-overlay.formats": "Formats", + "rules-overlay.cancel": "Cancel", + "rules-overlay.save": "Save", + "rules-overlay.hint-formats": "Set preset formats, font colors, header style, and default font.", + "extensions-hub-connections.scope-controls": "Scope controls", + "extensions-hub-connections.enabled": "Enabled", + "extensions-hub-connections.quick-setup": "Quick setup", + "custom-gateway-settings.configured-gateways": "Configured gateways", + "custom-gateway-settings.update-gateway": "Update gateway", + "recovery.backupFailed": "Backup failed", + "recovery.refreshFailed": "Refresh failed", + "recovery.clearing": "Clearing…", + "recovery.clearFailed": "Clear failed", + "recovery.restoring": "Restoring…", + "recovery.restoreFailed": "Restore failed", + "recovery.deleting": "Deleting…", + "recovery.deleteFailed": "Delete failed", + "recovery.loadFailed": "Load failed", + "extensions-hub-skills.install-hint": "Paste a SKILL.md document below to install an external skill.", + "extensions-hub-skills.footer-hint": "Skills are instruction documents the AI reads on-demand to learn new workflows. They don't run code — they teach.", + "web-search-setup.helper-instructions": "Open Terminal · paste · press Enter · wait for \"Proxy listening\"", + "web-search-setup.checking-helper": "Checking helper…", + "web-search-setup.helper-not-detected": "Helper not detected yet — keep terminal open and try again.", + "web-search-setup.check-failed": "Could not check helper status right now.", + "web-search-setup.saving": "Saving…", + "web-search-setup.check-config": "Check your API key and proxy configuration in /tools.", + "web-search-setup.checking-setup": "Checking search setup…", + "bridge-setup.keepRunning": "Keep it running, then try again.", + "bridge-setup.setValidUrlFirst": "Set a valid bridge URL first, then test again.", + "bridge-setup.checkingBridge": "Checking bridge…", + "bridge-setup.cannotCheckBridge": "Could not check bridge status right now.", + "disclosure-bar.changeInSettings": "Change anytime in Settings", + "disclosure-bar.changeInSettingsMuted": "· Change anytime in Settings", + "files-dialog.emptyTitle": "Give Pi more context", + "files-dialog.emptyDescription": "Upload documents, data, or reference material to help Pi give better answers.", + "files-dialog.uploadButtonText": "Upload files", + "files-dialog.emptyHint": "Files are stored locally in your browser.", + "files-dialog.filterEmptyTitle": "No matching files", + "files-dialog.filterEmptyDescription": "Try a different filter term.", + "files-dialog.closeLabel": "Close files", + "files-dialog.title": "Files", + "files-dialog.subtitle": "Documents available to Pi", + "files-dialog.detailBackButton": "Back to file list", + "files-dialog.uploadButton": "Upload", + "files-dialog.connectFolderButton": "Connect folder", + "files-dialog.placeholder": "Filter files…", + "files-dialog.storageUnavailable": "Storage unavailable", + "files-dialog-actions.copyContent": "Copy content", + "files-dialog-actions.download": "Download", + "files-dialog-actions.rename": "Rename", + "files-dialog-actions.delete": "Delete", + "files-dialog-actions.cancel": "Cancel", + "render-csv-table.copied": "Copied!", + "provider.proxy.command": "npx pi-for-excel-proxy", + "files-dialog-actions.open": "Open ↗", + "status-popovers.thinkingLevel": "Thinking level", + "status-popovers.contextUsage": "Context usage", + "experimental.tip": "Tip: use /experimental on , /experimental off , /experimental toggle , /experimental tmux-bridge-url , /experimental tmux-bridge-token , or /experimental tmux-status.", + "rules.guidance.default": "Guidance given to Pi in all your conversations. Pi can also update these when you tell it your preferences — e.g. \"always use EUR\".", + "status.thinking.aria": "Thinking level {level}", + "status.context.aria": "Context usage {pct}% of {label}", + "provider.keyAria": "API key for {label}", + "rules.guidance.workbook": "Guidance given to Pi only when it reads this file.", + "experimental.dark_mode.title": "Dark mode", + "experimental.dark_mode.desc": "Enable Office/theme-driven dark mode for the task pane UI.", + "experimental.remote_urls.title": "Remote extension URLs", + "experimental.remote_urls.desc": "Allow loading extensions from remote http(s) URLs.", + "experimental.remote_urls.warning": "Unsafe: remote extension code can read workbook data and credentials.", + "experimental.permission_gates.title": "Extension permission gates", + "experimental.permission_gates.desc": "Enforce per-extension capability permissions when extensions activate.", + "experimental.sandbox_rollback.title": "Extension sandbox rollback", + "experimental.sandbox_rollback.desc": "Temporarily route untrusted extensions back to host runtime (kill switch).", + "experimental.sandbox_rollback.warning": "Use only as a rollback path. Default behavior runs untrusted extensions in sandbox iframes.", + "experimental.widget_v2.title": "Extension widget API v2", + "experimental.widget_v2.desc": "Enable additive multi-widget lifecycle APIs (upsert/remove/clear) with deterministic placement.", + "runtime.error.noApiKey": "No API key available for provider \"{provider}\".", + "extensions-hub-connections.noApiKey": "No API key", + "extensions-hub-connections.ready": "Ready", + "status.context.tooltip": "How much of Pi's memory (context window) this conversation is using.", + "status.context.popoverFallback": "How much of Pi's memory this conversation is using.", + "status.context.strongAction": "Use /compact to free space or /new to start fresh.", + "status.context.softAction": "Consider using /compact to free space or /new to start fresh.", + "status.context.full": "Context is full — the next message will fail.", + "status.context.severe": "Context {pct}% full — responses may become less reliable.", + "status.context.warning": "Context {pct}% full.", + "status.context.tokens": "{used} / {total} tokens", + "status.thinking.offHint": "Fastest — no reasoning step", + "status.thinking.minimalHint": "Quick — light reasoning", + "status.thinking.lowHint": "Fast — moderate reasoning", + "status.thinking.mediumHint": "Balanced — solid reasoning", + "status.thinking.highHint": "Slow — thorough reasoning", + "status.thinking.xhighHint": "Slowest — deepest reasoning", + "status-popovers.compactTitle": "Compact conversation", + "status-popovers.compactDesc": "Summarize earlier messages to free space.", + "status-popovers.newTitle": "Start new chat", + "status-popovers.newDesc": "Open a fresh tab with empty context.", + "hint.explain.label": "Explain this workbook", + "hint.quality.label": "Quality check this workbook", + "hint.financial.label": "Build my financial model", + "hint.format.label": "Format this sheet", + "hint.explain.prompt": "Read through the entire workbook — every sheet, its structure, formulas, and named ranges. Then write a clear overview and user manual for this workbook.\nCover: what the workbook does, how it's organized, the logic flow between sheets, where inputs live and where outputs are derived.\nIf it's a model: explain the key assumptions (and where to change them), the calculation logic, and how outputs depend on inputs.\nIf it's data: explain what the data represents, the key fields, any derived columns, and notable patterns or gaps.\nStructure your explanation like documentation — start with a summary, then walk through each sheet's role.", + "hint.quality.prompt": "Review this workbook for errors and issues across logic, assumptions, and formatting:\n- Logic: broken or circular references, hardcoded numbers inside formulas, inconsistent formula patterns across rows/columns, missing links between sheets, #REF or #VALUE errors.\n- Assumptions: flag any key assumptions (e.g. growth rates, discount rates, margins) — are they reasonable? Are they clearly labelled and easy to find, or buried in formulas?\n- Formatting: inconsistent number formats within columns, missing or misaligned headers, unlabelled input cells, inconsistent decimal places or currency symbols, rows/columns that break the visual pattern.\nSummarize your findings as a prioritized list of recommendations, grouped by severity.", + "hint.financial.prompt": "First, read through the entire workbook — every sheet, its structure, formulas, named ranges, and any existing data. Form a clear picture of what's already here and how it's organized.\nIf the workbook is blank or mostly empty: ask me what kind of financial model I need — for example a DCF, LBO, three-statement model, budget, forecast, or comparison — then build it step by step, starting with the assumptions.\nIf there's a partially complete model: explain the current structure, the logic flow, key assumptions, and what's missing or incomplete. Offer to extend or finish it.\nIf there's data but no model: explain what the data represents, suggest what could be modelled from it, and offer to build it.", + "hint.format.prompt": "Review this worksheet and infer the correct format for each cell from context, then apply formatting including:\n- Number formats (currency, percentages, dates, integers vs. decimals)\n- Font colour coding (e.g. blue for inputs, black for formulas)\n- Cell styles for inputs, outputs, and headers\n- Consistent headers and section labels\nEnsure formats are consistent: for example, if all other cells in a column use one decimal place, apply the same. If a row is bold or italicised, extend that to any unformatted cells in the row.\nAfter formatting, read back the sheet and verify your changes look correct.", + "status.mode.toast": "{mode} mode.", + "ext-hub-connections.allowExternal": "Allow Pi to search the web and call external services", + "ext-hub-connections.externalToolsEnabled": "External tools enabled", + "ext-hub-connections.externalToolsDisabled": "External tools disabled", + "ext-hub-connections.connectionsEmpty": "Installed extensions haven't registered any connections.", + "ext-hub-connections.availability": "Availability", + "ext-hub-connections.enableSession": "Enable for this session", + "ext-hub-connections.enableWorkbook": "Enable for workbook ({label})", + "ext-hub-connections.scopeUnavailable": "Workbook scope unavailable", + "ext-hub-connections.noMcpServers": "No MCP servers configured.\nAdd one to connect external tools.", + "ext-hub-connections.serverNamePlaceholder": "Server name", + "ext-hub-connections.serverUrlPlaceholder": "https://server-url/rpc", + "ext-hub-connections.bearerTokenPlaceholder": "Bearer token (optional)", + "ext-hub-connections.addButton": "Add", + "ext-hub-connections.pythonName": "Python bridge", + "ext-hub-connections.pythonDesc": "Execute Python code in a local environment", + "ext-hub-connections.tmuxName": "tmux bridge", + "ext-hub-connections.tmuxDesc": "Remote shell sessions via tmux", + "ext-hub-connections.quickSetup": "Quick setup", + "ext-hub-connections.bridgeUrl": "Bridge URL", + "ext-hub-connections.setupHint": "Open Terminal · paste · press Enter · type y and Enter if prompted · leave open", + "ext-hub-connections.configured": "Configured", + "ext-hub-connections.defaultUrl": "Default URL", + "ext-hub-connections.saveButton": "Save", + "ext-hub-connections.clearButton": "Clear", + "ext-hub-connections.testButton": "Test", + "ext-hub-connections.removeButton": "Remove", + "ext-hub-connections.badgeEnabled": "Enabled", + "ext-hub-connections.badgeDisabled": "Disabled", + "ext-hub-connections.badgeNoToken": "(none)", + "ext-hub-connections.scopeSessionOnly": "Session only", + "ext-hub-connections.scopeOff": "Off in all scopes", + "ext-hub-connections.uninstall": "Uninstall", + "ext-hub-connections.installTab": "Install", + "ext-hub-connections.extConnections": "Extension connections", + "ext-hub-skills.installed": "Installed", + "ext-hub-skills.noExternalSkills": "No external skills installed.\nPaste a SKILL.md below to add one.", + "ext-hub-skills.install": "Install skill", + "perm.commands.register": "register commands", + "perm.tools.register": "register tools", + "perm.agent.read": "read agent state", + "perm.agent.events.read": "read agent events", + "perm.ui.overlay": "show overlays", + "perm.ui.widget": "show widgets", + "perm.ui.toast": "show toasts", + "perm.llm.complete": "call LLM completions", + "perm.http.fetch": "fetch external HTTP resources", + "perm.storage.readwrite": "read/write extension storage", + "perm.connections.readwrite": "manage connection definitions and secrets", + "perm.connections.secrets.read": "read raw connection secret values", + "perm.clipboard.write": "write clipboard text", + "perm.agent.context.write": "inject agent context", + "perm.agent.steer": "steer active agent runs", + "perm.agent.followup": "queue agent follow-up messages", + "perm.skills.read": "read skill catalog", + "perm.skills.write": "install/uninstall external skills", + "perm.download.file": "trigger file downloads", + "perm.trust.builtin": "builtin", + "perm.trust.local-module": "local module", + "perm.trust.inline-code": "inline code", + "perm.trust.remote-url": "remote URL", + "ext-hub-plugins.installed": "Installed", + "ext-hub-plugins.commands": "Commands", + "ext-hub-plugins.permissions": "Permissions", + "ext-hub-plugins.install": "Install", + "ext-hub-plugins.pasteUrl": "Paste a plugin URL…", + "ext-hub-plugins.installButton": "Install", + "ext-hub-plugins.uninstall": "Uninstall", + "ext-hub-plugins.empty": "No plugins installed.\nPi can build plugins, or install one from a URL.", + "ext-hub-skills.statusLine": "{active} skills active ({bundled} bundled, {external} external)", + "files-dialog-filtering.sectionBuiltinDocs": "BUILT-IN DOCS", + "files-dialog-filtering.connectFolder": "Connect folder", + "files-backend.sandboxedWorkspace": "Sandboxed workspace", + "files-dialog.sandboxed": "Sandboxed workspace", + "files-dialog.fileCount": "{count} files", + "files-dialog.toast.connectFolderFailed": "Connect folder failed: {error}", + "files-dialog.toast.refreshFailed": "Could not refresh files: {error}", + "files-dialog.toast.uploadFailed": "Upload failed: {error}", + "files-dialog.toast.loadFailed": "Could not load files: {error}", + "shortcuts.section.chat": "Chat", + "shortcuts.section.tabs": "Tabs", + "shortcuts.section.navigation": "Navigation", + "shortcuts.section.system": "System", + "resume.sessionMeta": "{count} messages · {date}", + "resume.recentlyClosedMeta": "Closed {date} · Reopens in new tab", + "resume.defaultActionHint": "Default action: {action}", + "rules.charsCount": "{chars} / {limit} chars", + "date.justNow": "just now", + "date.minutesAgo": "{n}m ago", + "date.hoursAgo": "{n}h ago", + "date.daysAgo": "{n}d ago", + "resume.crossWorkbookReplace": "This session was created for a different workbook. Resume anyway and replace the current chat?", + "resume.crossWorkbookNewTab": "This session was created for a different workbook. Resume anyway in a new tab?", + "shortcuts.keys.enterStreaming": "Enter (while streaming)", + "settings.proxy.disabled": "Proxy disabled.", + "settings.proxy.connected": "Proxy connected at {url}", + "custom-gateway.configLabelName": "Name", + "custom-gateway.configLabelEndpoint": "Endpoint", + "custom-gateway.configLabelModel": "Model", + "custom-gateway.configLabelContextWindow": "Max context tokens", + "custom-gateway.configLabelApiKey": "API key", + "custom-gateway.apiKeyPlaceholder": "API key (optional for local servers)", + "custom-gateway.contextWindowHint": "Used for Pi's local context budgeting and auto-compaction. Set this to your gateway model's real context window.", + "custom-gateway.gatewayEndpoint": "Endpoint: {url}", + "custom-gateway.gatewayModel": "Model: {id}", + "custom-gateway.gatewayContextWindow": "Max context: {tokens}", + "custom-gateway.gatewayApiKeyConfigured": "API key: configured", + "custom-gateway.gatewayApiKeyNone": "API key: none", + "custom-gateway.deletedGateway": "Deleted gateway {name}.", + "custom-gateway.updatedGateway": "Updated gateway {name}.", + "custom-gateway.savedGateway": "Saved gateway {name}.", + "workbook.currentWorkbook": "Current workbook", + "init.undo": "Undo", + "init.closedTab": "Closed {title}", + "init.closedOtherTabs": "Closed {count} other tabs", + "compat.thought": "Thought", + "init.llmError": "LLM error: {error}", + "tools.action.overview": "Overview", + "tools.workbookOverview": "Workbook Overview" +} \ No newline at end of file diff --git a/src/language/locales/zh-CN.json b/src/language/locales/zh-CN.json new file mode 100644 index 00000000..f652b8ad --- /dev/null +++ b/src/language/locales/zh-CN.json @@ -0,0 +1,911 @@ +{ + "input.attach.aria": "浏览文件", + "input.chat.aria": "聊天消息", + "input.send.aria": "发送", + "input.stop.aria": "停止", + "input.drop.hint": "拖放文件以导入到文件工作区", + "input.streaming.placeholder": "引导回复 (↵) · 新问题 (⌥↵)", + "input.placeholder.ask": "询问此工作簿…", + "input.placeholder.commands": "输入 / 查看命令…", + "input.placeholder.edit": "让 Pi 编辑此工作簿…", + "input.placeholder.summarize": "总结此工作簿…", + "working.default": "工作中…", + "working.hint.escape": "按 Esc 停止", + "working.hint.reasoning": "⇧Tab 调整推理深度", + "working.hint.commands": "输入 / 查看命令", + "working.hint.collapse": "⌃O 折叠工具详情", + "working.hint.redirect": "按 Enter 重定向 Pi", + "whimsical.schlepping": "拖沓中…", + "whimsical.combobulating": "梳理中…", + "whimsical.vibing": "感受中…", + "whimsical.concocting": "调制中…", + "whimsical.transmuting": "转化中…", + "whimsical.pontificating": "高谈阔论中…", + "whimsical.cogitating": "深思中…", + "whimsical.noodling": "琢磨中…", + "whimsical.percolating": "渗透中…", + "whimsical.ruminating": "反复思考中…", + "whimsical.simmering": "慢炖中…", + "whimsical.marinating": "腌制中…", + "whimsical.fermenting": "发酵中…", + "whimsical.brewing": "酝酿中…", + "whimsical.steeping": "浸泡中…", + "whimsical.contemplating": "沉思中…", + "whimsical.musing": "冥想中…", + "whimsical.pondering": "考量中…", + "whimsical.mulling": "推敲中…", + "whimsical.daydreaming": "做白日梦中…", + "whimsical.tinkering": "修补中…", + "whimsical.finagling": "耍花招中…", + "whimsical.wrangling": "驯服中…", + "whimsical.meandering": "漫游中…", + "whimsical.moseying": "溜达中…", + "whimsical.pottering": "闲逛中…", + "whimsical.bumbling": "笨手笨脚中…", + "whimsical.futzing": "捣鼓中…", + "whimsical.kerfuffling": "混乱中…", + "whimsical.bamboozling": "忽悠中…", + "whimsical.discombobulating": "不知所措中…", + "whimsical.recombobulating": "重新整理中…", + "whimsical.confabulating": "胡诌中…", + "whimsical.flummoxing": "困惑中…", + "whimsical.befuddling": "迷糊中…", + "whimsical.effervescing": "沸腾中…", + "whimsical.fizzing": "冒泡中…", + "whimsical.bubbling": "起泡中…", + "whimsical.scintillating": "闪耀中…", + "whimsical.improvising": "即兴发挥中…", + "whimsical.frolicking": "嬉戏中…", + "whimsical.calculating": "计算中…", + "whimsical.recalculating": "重新计算中…", + "whimsical.pivoting": "透视中…", + "whimsical.subtotaling": "分类汇总中…", + "whimsical.autofilling": "自动填充中…", + "whimsical.tabulating": "制表中…", + "whimsical.auditing": "审计中…", + "whimsical.reconciling": "对账中…", + "whimsical.amortizing": "摊销中…", + "whimsical.compounding": "复利计算中…", + "whimsical.accruing": "计提中…", + "whimsical.depreciating": "折旧中…", + "whimsical.forecasting": "预测中…", + "whimsical.extrapolating": "外推中…", + "whimsical.interpolating": "内插中…", + "whimsical.consulting_void": "询问虚空…", + "whimsical.asking_electrons": "询问电子…", + "whimsical.negotiating_entropy": "与熵谈判…", + "whimsical.waxing_philosophical": "大谈哲理…", + "whimsical.reading_tea_leaves": "解读茶叶…", + "whimsical.magic_8ball": "摇晃魔法八球…", + "whimsical.warming_hamsters": "给仓鼠热身…", + "whimsical.little_think": "小小思考一下…", + "whimsical.stroking_chin": "若有所思地摸下巴…", + "whimsical.squinting": "眯眼看问题…", + "whimsical.staring_abyss": "凝视深渊…", + "whimsical.abyss_staring_back": "深渊也在凝视你…", + "whimsical.enlightenment": "悟道中…", + "whimsical.consulting_oracle": "请教神谕…", + "whimsical.reticulating_splines": "编织样条曲线…", + "whimsical.flux_capacitor": "校准通量电容器…", + "whimsical.hoping": "寄望于最好的结果…", + "whimsical.manifesting": "显化解决方案…", + "whimsical.willing": "用意念使其存在…", + "whimsical.believing": "用力相信…", + "whimsical.reading_room": "察言观色…", + "whimsical.kicking_tires": "踢踢轮胎…", + "whimsical.dusting_neurons": "清理神经元灰尘…", + "whimsical.rearranging_deck_chairs": "重新摆放甲板椅…", + "whimsical.circular_reference": "安抚循环引用…", + "whimsical.bribing_formula_bar": "贿赂公式栏…", + "whimsical.rounding_errors": "与舍入误差讲理…", + "whimsical.print_preview": "恳求打印预览…", + "whimsical.herding_cells": "把单元格赶入对齐…", + "whimsical.wrestling_arrays": "与数组公式搏斗…", + "whimsical.taming_ref": "驯服野生的 #REF! 错误…", + "whimsical.missing_penny": "寻找缺失的一分钱…", + "whimsical.spreadsheet_gods": "请教电子表格之神…", + "whimsical.reticulating_spreadsheets": "编织电子表格…", + "whimsical.massaging_margins": "按摩页边距…", + "whimsical.merged_cells": "与合并单元格吵架…", + "whimsical.conditional_formatting": "与条件格式化调情…", + "whimsical.column_widths": "与列宽谈判…", + "whimsical.index_match": "礼貌地请求 INDEX MATCH…", + "whimsical.befriending_ribbon": "结交 Ribbon 菜单…", + "whimsical.tiptoeing_macros": "蹑手蹑脚绕过宏…", + "whimsical.convincing_cells": "说服单元格配合…", + "whimsical.data_validation": "喂食数据验证…", + "whimsical.whatif_analysis": "预热假设分析…", + "whimsical.cross_referencing": "交叉引用工作表…", + "whimsical.auditing_formula_trail": "审计公式轨迹…", + "whimsical.tracing_precedents": "追踪引用单元格…", + "whimsical.evaluating_dependents": "评估从属单元格…", + "whimsical.freezing_panes": "深思熟虑地冻结窗格…", + "whimsical.persuading_offset": "说服 OFFSET 配合…", + "whimsical.checking_indirect": "检查 INDIRECT 的内部…", + "whimsical.balancing_books": "平账中…", + "whimsical.crunching_numbers": "计算数字中…", + "whimsical.counting_beans": "数豆子中…", + "whimsical.discounting_cash_flows": "折现未来现金流…", + "whimsical.adjusting_seasonality": "调整季节性因素…", + "whimsical.monte_carlo": "运行蒙特卡洛模拟…", + "whimsical.stress_testing": "压力测试模型…", + "whimsical.sanity_checking": "合理性检查合计…", + "whimsical.reconciling_penny": "精确到分地对账…", + "whimsical.marking_to_market": "按市价估值…", + "whimsical.rolling_forecast": "滚动预测…", + "whimsical.building_bridge": "搭建桥梁…", + "whimsical.waterfalling_revenue": "瀑布分析收入…", + "whimsical.sensitizing": "敏感性分析假设…", + "whimsical.triangulating": "三角验证估值…", + "whimsical.normalizing_ebitda": "标准化 EBITDA…", + "whimsical.checking_foot": "检查脚注…", + "whimsical.tying_balance_sheet": "核对资产负债表…", + "whimsical.hardcoding_overrides": "硬编码覆盖值…", + "whimsical.midyear_convention": "忘记年中惯例…", + "confirm.cancel": "取消", + "confirm.confirm": "确认", + "confirm.error.unavailable": "确认界面在当前环境中不可用。", + "textinput.cancel": "取消", + "textinput.save": "保存", + "textinput.error.unavailable": "文本输入界面在当前环境中不可用。", + "loading.initializing": "初始化中…", + "sidebar.empty.tagline": "理解并操作 Excel。记住你的偏好。自行构建工具。", + "sidebar.empty.hint.title": "插入到输入框 — 发送前可编辑。", + "sidebar.tabs.scroll_left": "向左滚动标签页", + "sidebar.tabs.scroll_right": "向右滚动标签页", + "sidebar.tabs.new": "新建标签页", + "sidebar.tabs.open": "打开标签页 {title}", + "sidebar.tabs.close": "关闭标签页", + "sidebar.tabs.close.wait": "等待工作簿更改完成", + "sidebar.tabs.lock": "锁定中…", + "sidebar.utilities.aria": "设置和工具", + "sidebar.menu.setup": "设置", + "sidebar.menu.extensions": "扩展", + "sidebar.menu.files": "文件", + "sidebar.menu.rules": "规则", + "sidebar.menu.resume": "恢复会话", + "sidebar.menu.backups": "备份", + "sidebar.menu.keyboard_shortcuts": "键盘快捷键", + "sidebar.contextmenu.rename": "重命名标签页…", + "sidebar.contextmenu.duplicate": "复制标签页", + "sidebar.contextmenu.move_left": "向左移动", + "sidebar.contextmenu.move_right": "向右移动", + "sidebar.contextmenu.close_others": "关闭其他标签页", + "sidebar.contextmenu.close": "关闭标签页", + "sidebar.contextmenu.aria": "标签页 {title} 的操作", + "sidebar.context_pill.no_calls": "上下文 · 此会话尚无调用", + "sidebar.context_pill.no_snapshots": "此会话尚无负载快照。", + "sidebar.context_pill.hint": "在此标签页中发送提示以捕获调用级上下文详情。", + "sidebar.context_pill.recent_calls": "最近调用", + "sidebar.context_pill.tools": "工具", + "sidebar.context_pill.copy_json": "复制 JSON", + "sidebar.context_pill.system_prompt": "系统提示词", + "sidebar.context_pill.stripped": "已剥离", + "sidebar.context_pill.first": "首次", + "sidebar.context_pill.continuation": "延续", + "sidebar.context_pill.stable": "稳定", + "sidebar.context_pill.no": "否(稳定)", + "sidebar.context_pill.yes": "是({reasons})", + "welcome.title": "Pi for Excel 汉化版", + "welcome.subtitle": "连接 AI 提供者,开始使用", + "welcome.intro": "提供一款能读取您的表格、进行修改并开展调研的 AI 智能体——基于您现有的模型实现。", + "welcome.select_provider": "选择提供者", + "welcome.custom_gateway": "使用自定义 OpenAI 兼容网关", + "welcome.proxy.toggle_show": "登录遇到问题?配置本地代理", + "welcome.proxy.toggle_hide": "隐藏代理设置", + "welcome.proxy.title": "本地 HTTPS 代理", + "welcome.proxy.enabled": "已启用", + "welcome.proxy.save": "保存", + "welcome.proxy.guide": "详细指南", + "welcome.proxy.hint.prefix": "仅在 OAuth 登录被 CORS 阻止时需要。请保持此 URL 为 ", + "welcome.proxy.hint.suffix": ",运行本地 HTTPS 代理,然后开启此开关。", + "welcome.proxy.hint.end": "。", + "welcome.toast.cannot_open_settings": "无法打开自定义网关设置", + "welcome.toast.proxy_saved": "代理设置已保存", + "welcome.toast.proxy_failed": "代理设置保存失败", + "welcome.toast.connected": "{label} 已连接 — 试试\"解释这个工作簿\"", + "welcome.toast.disconnected": "{label} 已断开", + "provider.proxy_gate.title": "登录前还需一步", + "provider.proxy_gate.message": "此登录方式需要你的 Mac 上运行一个小助手。打开终端应用并粘贴此命令:", + "provider.proxy_gate.copy": "复制", + "provider.proxy_gate.copied": "已复制!", + "provider.proxy_gate.hint": "等待终端中显示\"Proxy listening\",然后点击重试。", + "provider.proxy_gate.guide": "分步指南 →", + "provider.proxy_gate.cancel": "取消", + "provider.proxy_gate.retry": "重试", + "provider.proxy_gate.checking": "检测中…", + "provider.proxy_gate.not_detected": "尚未检测到助手 — 请确保终端中显示\"Proxy listening\",然后重试。", + "provider.prompt.cancel": "取消", + "provider.prompt.continue": "继续", + "provider.prompt.cancelled": "提示已取消", + "provider.error.empty_key": "API 密钥为空", + "provider.error.oauth_code_as_key": "这看起来像 OAuth 授权码(code#state)。请使用\"Login with Anthropic\"并在提示时粘贴(不要作为 API 密钥保存)。", + "provider.error.oauth_url_codex": "这看起来像 OAuth 重定向 URL/代码。请使用\"Login with OpenAI (ChatGPT)\"并在登录提示中粘贴(不要作为 API 密钥保存)。", + "provider.error.oauth_url_google": "这看起来像 OAuth 重定向 URL/代码。请使用\"Login with Google …\"并在登录提示中粘贴(不要作为 API 密钥保存)。", + "provider.error.oauth_url_google_api": "这看起来像 OAuth 重定向 URL/代码。请在此处使用 Google API 密钥认证,或使用专门的 Google OAuth 登录行。", + "provider.connected": "✓ 已连接", + "provider.set_up": "设置 →", + "provider.disconnect": "断开 {label}", + "provider.login_with": "使用 {label} 登录", + "provider.or_api_key": "或输入 API 密钥", + "provider.save": "保存", + "provider.opening_login": "正在打开登录…", + "provider.testing": "测试中…", + "provider.login_failed": "登录失败", + "provider.save_failed": "保存密钥失败", + "provider.save_failed_msg": "保存密钥失败:{msg}", + "provider.disconnect_failed": "断开失败", + "provider.disconnect_failed_msg": "断开失败:{msg}", + "provider.disconnecting": "正在断开…", + "provider.cors_error": "登录无法连接 — 此提供者需要你的 Mac 上运行一个助手。打开终端并运行:", + "provider.cors_error.retry": ",然后重试。", + "provider.oauth.helper.anthropic": "登录完成后,浏览器可能会显示一个无法访问的 localhost 页面 — 这是正常的。从浏览器地址栏复制完整 URL 并粘贴到这里。", + "provider.oauth.helper.openai": "登录后,浏览器会显示一个\"无法访问\"的页面 — 这是正常的!从浏览器地址栏复制完整 URL 并粘贴到这里。", + "provider.oauth.helper.google": "登录后,浏览器会显示一个\"无法访问\"的页面 — 这是正常的!从浏览器地址栏复制完整 URL 并粘贴到这里。", + "provider.placeholder.anthropic": "sk-ant-api… 或 sk-ant-oat…", + "provider.placeholder.chatgpt": "ChatGPT OAuth 访问令牌", + "provider.placeholder.google_oauth": "Google OAuth 凭证 JSON", + "provider.placeholder.api_key": "输入 API 密钥", + "provider.name.anthropic": "Anthropic", + "provider.name.openai_chatgpt": "OpenAI (ChatGPT)", + "provider.name.google_code_assist": "Google Code Assist", + "provider.name.google_antigravity": "Google Antigravity", + "provider.name.github_copilot": "GitHub Copilot", + "provider.name.openai_api": "OpenAI (API)", + "provider.name.google_api": "Google Gemini (API)", + "provider.name.deepseek": "DeepSeek", + "provider.name.bedrock": "Amazon Bedrock", + "provider.name.mistral": "Mistral", + "provider.name.groq": "Groq", + "provider.name.xai": "xAI / Grok", + "provider.desc.claude": "Claude Pro/Max", + "provider.desc.openai_sub": "Plus/Pro 订阅", + "provider.desc.gemini_account": "通过 Google 账号使用 Gemini", + "provider.desc.antigravity": "Gemini/Claude/GPT-OSS", + "provider.desc.api_key": "API 密钥", + "status.no_session": "无活动会话", + "status.select_model": "选择模型", + "status.thinking.off": "关闭", + "status.thinking.min": "最小", + "status.thinking.low": "低", + "status.thinking.medium": "中", + "status.thinking.high": "高", + "status.thinking.max": "最大", + "status.thinking.flash": "思考深度:{level}(下一轮生效)", + "status.thinking.tooltip": "Pi 回答前的推理深度 — 越高越慢但越彻底。点击选择,或按 ⇧Tab 切换。", + "status.mode.auto": "自动", + "status.mode.confirm": "确认", + "status.mode.auto.tooltip": "自动:Pi 立即应用工作簿更改。点击切换为确认模式。", + "status.mode.confirm.tooltip": "确认:Pi 在每次更改前询问。点击切换为自动模式。", + "status.model.tooltip": "切换此会话的 AI 模型。", + "status.lock.waiting": "工作簿写入正在另一个会话后面排队。", + "status.lock.active": "此会话当前持有工作簿写入锁。", + "status.lock.label": "锁定", + "status.lock.waiting_label": "锁定中…", + "session.tab.default": "对话 {number}", + "settings.title": "设置", + "settings.subtitle": "提供者、代理和偏好设置", + "settings.close": "关闭设置", + "settings.tabs.aria": "设置选项卡", + "settings.tab.providers": "提供者", + "settings.tab.more": "更多", + "settings.section.providers": "提供者", + "settings.section.providers.hint": "连接提供者以使用其模型。", + "settings.section.proxy": "代理", + "settings.section.proxy.label": "代理", + "settings.section.proxy.sublabel": "通过本地代理路由 API 调用", + "settings.section.proxy.url": "URL", + "settings.section.proxy.disabled": "代理已禁用。", + "settings.section.proxy.connected": "代理已连接到 {url}", + "settings.section.proxy.not_reachable": "代理已在 {url} 启用,但当前无法访问。", + "settings.section.proxy.checking": "正在检测代理 {url}…", + "settings.section.proxy.recommended": "推荐 URL:", + "settings.section.proxy.keep_localhost": "。请保持在 localhost。", + "settings.section.proxy.guide": "安装和设置指南", + "settings.section.execution_mode": "执行模式", + "settings.section.language.label": "语言 / Language", + "settings.section.language.en": "英语", + "settings.section.execution.auto_mode": "自动模式", + "settings.section.execution.auto_sublabel": "Pi 立即应用更改而无需询问", + "settings.section.execution.confirm_hint": "关闭时,Pi 在每次更改前询问(确认模式)。", + "settings.section.advanced": "高级", + "settings.section.advanced.hint": "为规则、备份及键盘快捷键提供高级用户的快捷操作。", + "settings.section.advanced.fork_label": "模型切换时复制到新标签页", + "settings.section.advanced.fork_sublabel": "对于非空聊天,打开复制标签页而非原地切换。", + "settings.section.advanced.fork_default": "默认:原地切换(兼容 pi-mono)。", + "settings.section.experimental": "实验性功能", + "settings.section.experimental.hint": "高级及进行中的功能支持。", + "settings.button.rules": "规则和约定…", + "settings.button.backups": "备份…", + "settings.button.shortcuts": "键盘快捷键…", + "settings.toast.connected": "{label} 已连接", + "settings.toast.disconnected": "{label} 已断开", + "settings.toast.proxy_save_failed": "保存代理设置失败。", + "settings.toast.proxy_enabled": "代理已启用。", + "settings.toast.proxy_disabled": "代理已禁用。", + "settings.toast.proxy_url_invalid": "无效的代理 URL。", + "settings.toast.proxy_url_not_saved": "代理 URL 未保存:{error}", + "settings.toast.proxy_url_save_failed": "保存代理 URL 失败。", + "settings.toast.proxy_url_saved": "代理 URL 已保存。", + "settings.toast.auto_mode": "自动模式。", + "settings.toast.confirm_mode": "确认模式。", + "settings.toast.execution_failed": "无法更新执行模式。", + "settings.toast.fork_on": "模型切换将为非空聊天打开新标签页。", + "settings.toast.fork_off": "模型切换将保持在当前标签页。", + "settings.toast.fork_failed": "无法更新模型切换设置。", + "settings.warning.provider_state": "保存的提供者状态暂时不可用。你仍然可以连接提供者。", + "rules.title": "规则", + "rules.subtitle": "为所有文件、此工作簿和格式约定设置指导。", + "rules.close": "关闭规则", + "rules.tab.all_files": "我的所有文件", + "rules.tab.this_file": "此文件", + "rules.tab.formats": "格式", + "rules.cancel": "取消", + "rules.save": "保存", + "rules.workbook_tag": "工作簿:{label}", + "rules.counter": "{chars} / {limit} 字符", + "rules.placeholder.user": "你的偏好和习惯,例如:\\n• 始终使用 EUR 作为货币\\n• 日期格式为 yyyy-mm-dd\\n• 写入后检查循环引用", + "rules.placeholder.workbook": "关于此工作簿结构的备注,例如:\\n• Acme Corp 的 DCF 模型,FY2025\\n• 收入假设在 Inputs!B5:B15\\n• 不要修改 Summary 工作表", + "rules.hint.user": "在所有对话中给 Pi 的指导。当你告诉 Pi 你的偏好时,它也可以更新这些内容 — 例如\"始终使用 EUR\"。", + "rules.hint.workbook.no_id": "当前无法识别此工作簿——请先保存文件。", + "rules.hint.workbook": "仅在 Pi 读取此文件时给出的指导。", + "rules.hint.conventions": "设置预设格式、字体颜色、标题样式和默认字体。", + "rules.section.number_formats": "数字格式", + "rules.section.colors": "颜色(字体颜色)", + "rules.section.header_style": "标题样式", + "rules.section.default_font": "默认字体", + "rules.field.name": "名称", + "rules.field.description": "描述", + "rules.field.format": "格式", + "rules.field.positive": "正数", + "rules.field.negative": "负数", + "rules.field.zero": "零", + "rules.field.hardcoded_values": "硬编码值", + "rules.field.cross_sheet_links": "跨表链接", + "rules.field.fill": "填充", + "rules.field.font": "字体", + "rules.field.fill_color": "填充颜色", + "rules.field.font_color": "字体颜色", + "rules.field.bold": "粗体", + "rules.field.wrap_text": "自动换行", + "rules.field.font_name": "字体名称", + "rules.field.font_size": "字体大小", + "rules.field.dp": "小数位", + "rules.field.neg": "负数", + "rules.field.zero_style": "零值", + "rules.field.symbol": "符号", + "rules.toggle.on": "开", + "rules.toggle.off": "关", + "rules.preset.blank": "留空", + "rules.preset.custom": "自定义", + "rules.preset.blank_zero": "(留空)", + "rules.preset.negative.parens": "(1,234)", + "rules.preset.negative.minus": "-1,234", + "rules.preset.zero.dash": "--", + "rules.preset.zero.single_dash": "-", + "rules.preset.zero.zero": "0", + "rules.placeholder.optional": "可选", + "rules.placeholder.color": "#RRGGBB 或 rgb(r,g,b)", + "rules.placeholder.custom_format_reset": "自定义格式 — 使用快速选项重置", + "rules.button.add_custom": "添加自定义格式", + "rules.button.remove_preset": "移除预设", + "rules.header_preview.aria": "标题样式预览", + "rules.toast.saved": "规则已保存", + "shortcuts.title": "键盘快捷键", + "shortcuts.subtitle": "聊天、标签页、导航和系统快捷键的快速参考。", + "shortcuts.close": "关闭键盘快捷键", + "shortcuts.group.chat": "聊天", + "shortcuts.group.tabs": "标签页", + "shortcuts.group.navigation": "导航", + "shortcuts.group.system": "系统", + "shortcuts.desc.send": "发送消息", + "shortcuts.desc.interrupt": "打断并重定向", + "shortcuts.desc.queue": "排队后续消息", + "shortcuts.desc.restore_queue": "恢复排队的消息", + "shortcuts.desc.cycle_thinking": "切换思考深度", + "shortcuts.desc.new_tab": "新建标签页", + "shortcuts.desc.close_tab": "关闭标签页", + "shortcuts.desc.reopen_tab": "重新打开已关闭的标签页", + "shortcuts.desc.switch_tabs": "切换标签页(先退出输入框)", + "shortcuts.desc.prev_next_tab": "上一个 / 下一个标签页", + "shortcuts.desc.command_menu": "打开命令菜单", + "shortcuts.desc.navigate_menu": "导航菜单项", + "shortcuts.desc.focus_input": "聚焦聊天输入框", + "shortcuts.desc.toggle_focus": "切换焦点:工作表 ↔ 侧边栏", + "shortcuts.desc.toggle_focus_reverse": "切换焦点(反向)", + "shortcuts.desc.dismiss": "关闭弹窗 / 停止生成 / 退出输入框", + "command.model.desc": "切换 AI 模型", + "command.default_models.desc": "使用 Ctrl+P 切换模型", + "command.model.no_session": "无活动会话", + "command.settings.desc": "设置(提供者和高级选项)", + "command.login.desc": "打开提供者设置", + "command.yolo.desc": "切换执行模式(自动 vs 确认)", + "command.rules.desc": "编辑 Pi 的规则(所有文件 + 当前文件)", + "command.yolo.mode.auto": "{mode} 模式 — Pi 立即应用工作簿更改。", + "command.yolo.mode.safe": "{mode} 模式 — Pi 在每次工作簿更改前询问。", + "command.name.desc": "命名当前聊天会话", + "command.share_session.desc": "将会话分享为链接", + "command.new.desc": "新建聊天会话标签页", + "command.resume.desc": "恢复之前的会话(在新标签页中打开)", + "command.resume_here.desc": "恢复之前的会话到当前标签页", + "command.history.desc": "打开备份", + "command.backup.desc": "手动全工作簿备份(创建/列表/恢复/清除)", + "command.reopen.desc": "重新打开最近关闭的会话标签页", + "command.revert.desc": "还原最新的工作簿备份", + "session.toast.name_usage": "用法:/name 我的会话名称", + "session.toast.named": "会话已命名:{title}", + "session.toast.sharing_soon": "会话分享即将推出", + "session.toast.backup_created": "手动备份已创建:#{id}({size})。", + "session.toast.no_backups": "此工作簿没有手动全工作簿备份。", + "session.toast.backups_list": "手动备份(显示 {count} 个):{preview}", + "session.toast.backup_not_found": "找不到此工作簿的备份。", + "session.toast.backup_restored": "已下载备份 #{id}。在 Excel 中打开文件以恢复。", + "session.toast.backups_deleted": "已删除 {count} 个手动备份。", + "session.toast.backup_failed": "备份命令失败:{message}", + "session.toast.backup_usage": "用法:/backup [create|list [limit]|restore [id]|clear]", + "session.confirm.clear_title": "删除所有手动全工作簿备份?", + "session.confirm.clear_message": "这将删除活动工作簿的所有手动全工作簿备份。", + "session.confirm.clear_label": "全部删除", + "session.confirm.cancel": "取消", + "session.backup.unknown_error": "未知错误", + "export.command.desc": "导出 JSON(会话记录或审计日志)", + "export.toast.no_session": "无活动会话", + "export.toast.no_messages": "没有可导出的消息", + "export.toast.transcript_copied": "记录已复制({count} 条消息,{size}KB)", + "export.toast.copy_failed": "复制失败:{error}", + "export.toast.transcript_downloaded": "已下载记录({count} 条消息)", + "export.toast.audit_export_failed": "审计导出失败:{error}", + "export.toast.audit_copied": "审计日志已复制({count} 条记录,{size}KB)", + "export.toast.audit_downloaded": "已下载审计日志({count} 条记录)", + "export.toast.compact.few_messages": "消息太少,无法压缩", + "export.toast.compact.compacting": "正在压缩以释放上下文", + "export.toast.compact.no_model": "未配置压缩所用的模型", + "export.toast.compact.no_api_key": "没有 {provider} 的 API 密钥。请使用 /login 或 /settings。", + "export.toast.compact.memory_nudge": "压缩提醒:在旧消息中发现 {count} 个记忆{cue}。如需要,请将持久性信息保存到备注(rules via instructions)。", + "export.toast.compact.retrying": "压缩输入过大 — 正在用更强的截断重试", + "export.toast.compact.summarized": "已摘要 {count} 条消息", + "export.toast.compact.nothing": "没有可压缩的内容", + "export.toast.compact.failed": "压缩失败:{msg}", + "export.toast.compact.failed_error": "压缩失败", + "export.toast.compact.summary_unavailable": "摘要不可用", + "export.compact.prompt": "你是一个上下文摘要助手。你的任务是阅读用户与 AI 助手之间的对话,然后按照指定的精确格式生成结构化摘要。\\n\\n不要继续对话。不要回复对话中的任何问题。只输出结构化摘要。", + "command.plugins.desc": "管理已安装的插件(/extensions plugins 的别名)", + "bootstrap.fatalError": "初始化失败:{msg}", + "bootstrap.fatalTimeout": "初始化失败:任务窗格超时", + "bootstrap.initTimeoutWarning": "任务窗格初始化时间超出预期(>12秒)", + "bootstrap.officeUnavailable": "Office.js不可用", + "bridge-setup.bridgeDetected": "✓ 检测到桥接——请让助手重试。", + "bridge-setup.bridgeNotDetected": "尚未检测到桥接——保持终端打开并重试。", + "bridge-setup.checking": "检查中…", + "bridge-setup.dismiss": "关闭", + "bridge-setup.intro": "在终端中运行:", + "bridge-setup.pythonUnavailable": "Python不可用", + "bridge-setup.testConnection": "测试连接", + "bridge-setup.tmuxTitle": "终端访问不可用", + "clipboard.commandDescription": "将上一条助手消息复制到剪贴板", + "clipboard.commandName": "copy(复制)", + "clipboard.copied": "已复制到剪贴板", + "clipboard.noAgentMessage": "没有可复制的助手消息", + "custom-gateway.cancelButton": "取消", + "custom-gateway.deleteButton": "删除", + "custom-gateway.deleteConfirmMsg": "删除网关\"{name}\"?这将从此加载项中删除其存储的API密钥。", + "custom-gateway.deleteConfirmTitle": "删除自定义网关?", + "custom-gateway.editButton": "编辑", + "custom-gateway.endpointPlaceholder": "https://your-gateway.example.com/v1", + "custom-gateway.hint": "用于公司LLM网关或本地OpenAI兼容服务器。", + "custom-gateway.modelPlaceholder": "model-id(模型ID)", + "custom-gateway.namePlaceholder": "网关名称(可选)", + "custom-gateway.noGateways": "尚未配置自定义网关。", + "custom-gateway.saveGateway": "保存网关", + "custom-gateway.title": "自定义OpenAI兼容网关", + "disclosure-bar.customize": "自定义", + "disclosure-bar.done": "完成", + "disclosure-bar.externalServicesLabel": "外部服务(MCP)", + "disclosure-bar.extensionsLabel": "扩展与插件", + "disclosure-bar.gotIt": "知道了", + "disclosure-bar.skillsLabel": "技能", + "disclosure-bar.text": "Pi可以搜索网络、使用扩展程序以及连接到外部服务。", + "disclosure-bar.webSearchLabel": "网络搜索与页面抓取", + "experimental.advancedSecurity": "高级与安全控制选项", + "experimental.feature.none": "(无)", + "experimental.legacy_redirect": "外部工具(包括MCP)在 /tools 中管理,不在 /experimental 中。", + "experimental.usage.tmux_status": "用法:/experimental tmux-status", + "experimental.disabled": "已禁用", + "experimental.enabled": "已启用", + "experimental.hint": "进行中的功能,可能快速迭代更新。", + "experimental.noFeatures": "当前没有可用的实验性功能。", + "experimental.notWired": "当前仅作为标记占位符——该功能已规划但暂未实现。", + "experimental.ready": "已就绪", + "experimental.title": "实验性功能", + "extensions-hub-connections.addServer": "+ 添加服务器", + "extensions-hub-connections.apiKeyLabel": "API密钥", + "extensions-hub-connections.bridgesSection": "桥接", + "extensions-hub-connections.clearButton": "清除", + "extensions-hub-connections.externalTools": "外部工具", + "extensions-hub-connections.mcpSection": "MCP服务器", + "extensions-hub-connections.providerLabel": "提供商", + "extensions-hub-connections.saveButton": "保存", + "extensions-hub-connections.validateButton": "验证", + "extensions-hub-connections.webSearch": "网络搜索", + "extensions-hub-connections.webSearchConnected": "已连接", + "extensions-hub-connections.webSearchOff": "关闭", + "extensions-hub-skills.bundledSection": "内置技能", + "extensions-hub-skills.externalSection": "外部技能", + "extensions-hub-skills.installButton": "安装技能", + "extensions-hub-skills.installPlaceholder": "给Agent的指令...", + "extensions-hub-skills.installSection": "安装技能", + "extensions-hub-skills.bundledBadge": "内置", + "extensions-hub-skills.externalBadge": "外部", + "extensions-hub-skills.statusLine": "已激活{active}个技能", + "extensions-hub.closeLabel": "关闭扩展", + "extensions-hub.subtitle": "扩展Pi的连接、插件和技能", + "extensions-hub.tabConnections": "连接", + "extensions-hub.tabPlugins": "插件", + "extensions-hub.tabSkills": "技能", + "extensions-hub.title": "扩展", + "manifest.ButtonDesc": "打开Pi AI助手侧边栏", + "manifest.ButtonLabel": "打开Pi", + "manifest.Description": "开源的、多模型的Excel侧边栏AI加载项。由Pi驱动。", + "message-renderers.archived": "归档历史(仅UI)", + "message-renderers.noArchived": "(无归档消息)", + "message-renderers.noSummary": "(无摘要)", + "message-renderers.showEarlier": "显示早期消息", + "message-renderers.summarized": "已汇总 {count} 条消息", + "message-renderers.summaryLabel": "摘要", + "proxy-banner.action": "如何修复 →", + "proxy-banner.copied": "已复制", + "proxy-banner.copyCommand": "复制命令", + "proxy-banner.guideLink": "没有Node.js?查看安装指南→", + "proxy-banner.hint": "打开终端·粘贴·按回车·保持打开", + "proxy-banner.intro": "在终端中运行此命令并保持该窗口打开:", + "proxy-banner.warning": "代理未运行·某些功能将无法使用。", + "queue-display.followUpLabel": "跟进", + "queue-display.steerLabel": "引导", + "recovery.backupNotFound": "未找到备份", + "recovery.backupsSuffix": "个备份", + "recovery.capturing": "正在捕获…", + "recovery.clearAll": "全部清除", + "recovery.delete": "删除", + "recovery.downloadBackup": "下载备份", + "recovery.emptyFilter": "没有匹配当前筛选条件的备份。", + "recovery.emptyState": "尚无备份\nPi将在修改数据前在此保存快照。", + "recovery.keepAtMost": "最多保留", + "recovery.loading": "正在加载…", + "recovery.refresh": "刷新", + "recovery.refreshing": "正在刷新…", + "recovery.restore": "恢复", + "recovery.retentionSave": "保存", + "recovery.retentionSettings": "保留设置", + "recovery.searchPlaceholder": "搜索备份…", + "recovery.sortNewest": "↓ 最新", + "recovery.sortOldest": "↑ 最旧", + "recovery.subtitle": "Pi修改数据前保存的快照", + "recovery.title": "备份", + "recovery.warning": "在Excel中保存此工作簿时,备份将被清除。", + "resume.closeLabel": "关闭恢复会话", + "resume.sessionNotInRecentlyClosed": "会话已不在最近关闭中", + "resume.sessionNotFound": "未找到会话", + "resume.defaultTitle": "未命名", + "resume.noPreviousSessions": "无历史会话", + "resume.noSessionsForWorkbook": "此工作簿没有可用会话。", + "resume.openInNewTab": "在新标签页中打开", + "resume.recentlyClosed": "最近关闭", + "resume.recentlyClosedHint": "直接在新标签页中重新打开最近关闭的标签页。", + "resume.replaceCurrent": "替换当前标签页", + "resume.savedSessions": "已保存的会话", + "resume.showAllWorkbooks": "显示所有工作簿中的会话", + "resume.subtitle": "选择一个会话,在新标签页或当前标签页中恢复。", + "resume.title": "恢复会话", + "taskpane.title": "Pi for Excel", + "web-search-setup.checking": "检查中…", + "web-search-setup.dismiss": "关闭", + "web-search-setup.helperDetected": "✓ 检测到帮助程序", + "web-search-setup.retry": "重试", + "web-search-setup.save": "保存", + "web-search-setup.startHelper": "启动帮助程序(保持运行):", + "web-search-setup.tryAgain": "请让助手重试。", + "web-search-setup.validating": "验证中…", + "bridge-setup.copiedTitle": "已复制", + "bridge-setup.copyCommandTitle": "复制命令", + "command.debug.toggle": "切换调试界面(用量明细、额外诊断)", + "command.experimental.manage": "管理实验性功能", + "command.export.json": "导出 JSON(会话记录或审计日志)", + "command.export.summarize": "摘要旧消息以释放上下文", + "command.model.cycle": "用 Ctrl+P 切换模型", + "command.session.backup": "手动全工作簿备份", + "command.session.backups": "打开备份", + "command.session.name": "命名当前聊天会话", + "command.session.new": "新建聊天会话标签页", + "command.session.reopen": "重新打开最近关闭的会话标签页", + "command.session.resume": "恢复之前的会话", + "command.session.revert": "撤销最近的工作簿备份", + "command.session.share": "将会话分享为链接", + "command.settings.mode": "切换执行模式(自动 vs 确认)", + "command.settings.providers": "打开提供者设置", + "command.settings.rules": "编辑 Pi 的规则", + "export.compact.desc": "摘要旧消息以释放上下文", + "queue-display.shortcutHint": "↳ 按{shortcut}编辑队列消息", + "recovery.clear": "全部清除", + "recovery.close": "关闭备份", + "rules.preset.currency": "货币", + "rules.preset.integer": "整数", + "rules.preset.number": "数字", + "rules.preset.percent": "百分比", + "rules.preset.ratio": "比率", + "rules.preset.text": "文本", + "web-search-setup.stepLabel": "步骤{n} · 启动帮助程序(保持运行):", + "app.title": "Pi for Excel 汉化版", + "command.clipboard.no_session": "无活动会话", + "command.debug.enabled": "调试已启用", + "command.debug.disabled": "调试已禁用", + "command.debug.is_enabled": "调试已启用", + "command.debug.is_disabled": "调试已禁用", + "command.debug.usage": "用法:/debug [on|off|toggle|status]", + "files-dialog.toast.fileNotAvailable": "该文件不再可用。", + "web-search-setup.toast.enterApiKey": "请先输入 API 密钥。", + "web-search-setup.toast.ready": "✓ 网络搜索已就绪 — 请让助手重试。", + "keyboard-shortcuts.toast.noQueuedMessages": "没有可恢复的排队消息", + "editor-actions.toast.noActiveSession": "无活动会话", + "extensions-hub-plugins.toast.enterUrl": "请先输入 URL。", + "extensions-hub-skills.toast.pasteSkillMd": "请先粘贴 SKILL.md 文档。", + "extensions-hub-connections.toast.noApiKeyToValidate": "没有可验证的 API 密钥。", + "extensions-hub-connections.toast.enterApiKey": "请先输入 API 密钥。", + "extensions-hub-connections.toast.noActiveSession": "无活动会话", + "extensions-hub-connections.toast.workbookScopeUnavailable": "工作簿范围不可用", + "extensions-hub-extension-connections.toast.enterAtLeastOneField": "请至少输入一个字段保存。", + "init.securityWarning": "安全警告:代理 URL 不是 localhost — 它可以看到您的令牌和提示。", + "init.noActiveSession": "无活动会话", + "init.currentTabBusy": "当前标签页正忙 — 请使用新建标签页或等待其完成", + "init.couldNotReopenSession": "无法重新打开会话", + "init.sessionNotInRecentlyClosed": "会话已不在最近关闭中", + "init.noRecentlyClosedTab": "没有最近关闭的标签页", + "init.noBackupsYet": "该工作簿尚无备份", + "init.cantCloseLastTab": "无法关闭最后一个标签页", + "init.waitForChangesBeforeClose": "请等待工作簿更改完成后再关闭此标签页", + "init.sessionNotFound": "未找到会话", + "init.waitBeforeRenaming": "请等待此标签页完成后再重命名", + "init.tabNameReset": "标签页名称已重置", + "init.waitBeforeDuplicating": "请等待此标签页完成后再复制", + "init.noOtherTabs": "没有其他标签页", + "init.noTabsWereClosed": "没有标签页被关闭", + "init.waitBeforeChangingModels": "请等待此标签页完成后再切换模型", + "init.noFilesImported": "没有导入任何文件。", + "settings.lang.reloading": "语言已更改,正在重新加载...", + "settings.lang.saveFailed": "保存语言设置失败。", + "resume.couldNotReopen": "无法重新打开会话", + "rules-overlay.cost-of-goods": "销售成本", + "rules-overlay.sold": "已售", + "rules-overlay.cost-of-goods-ellipsis": "销售成本…", + "rules-overlay.custom-format-tip": "自定义格式——使用快速选项重置", + "rules-overlay.remove-preset": "移除预设", + "rules-overlay.number-formats": "数字格式", + "rules-overlay.add-custom-format": "添加自定义格式", + "rules-overlay.colors-font-color": "颜色(字体颜色)", + "rules-overlay.header-style": "标题样式", + "rules-overlay.default-font": "默认字体", + "rules-overlay.all-my-files": "我的所有文件", + "rules-overlay.this-file": "此文件", + "rules-overlay.formats": "格式", + "rules-overlay.cancel": "取消", + "rules-overlay.save": "保存", + "rules-overlay.hint-formats": "设置预设格式、字体颜色、标题样式和默认字体。", + "extensions-hub-connections.scope-controls": "范围控制", + "extensions-hub-connections.enabled": "已启用", + "extensions-hub-connections.quick-setup": "快速设置", + "custom-gateway-settings.configured-gateways": "已配置的网关", + "custom-gateway-settings.update-gateway": "更新网关", + "recovery.backupFailed": "备份失败", + "recovery.refreshFailed": "刷新失败", + "recovery.clearing": "清除中…", + "recovery.clearFailed": "清除失败", + "recovery.restoring": "恢复中…", + "recovery.restoreFailed": "恢复失败", + "recovery.deleting": "删除中…", + "recovery.deleteFailed": "删除失败", + "recovery.loadFailed": "加载失败", + "extensions-hub-skills.install-hint": "粘贴下方的SKILL.md文档以安装外部技能。", + "extensions-hub-skills.footer-hint": "技能是AI按需读取的指令文档,用于学习新工作流。它们不执行代码——而是提供指导。", + "web-search-setup.helper-instructions": "打开终端·粘贴·按回车·等待显示\"Proxy listening\"", + "web-search-setup.checking-helper": "检查帮助程序…", + "web-search-setup.helper-not-detected": "尚未检测到帮助程序——保持终端打开并重试。", + "web-search-setup.check-failed": "当前无法检查帮助程序状态。", + "web-search-setup.saving": "保存中…", + "web-search-setup.check-config": "请在/tools中检查您的API密钥和代理配置。", + "web-search-setup.checking-setup": "正在检查搜索设置…", + "bridge-setup.keepRunning": "保持运行,然后重试。", + "bridge-setup.setValidUrlFirst": "请先设置有效的桥接URL,然后再测试。", + "bridge-setup.checkingBridge": "检查桥接中…", + "bridge-setup.cannotCheckBridge": "当前无法检查桥接状态。", + "disclosure-bar.changeInSettings": "可随时在设置中更改", + "disclosure-bar.changeInSettingsMuted": "· 可随时在设置中更改", + "files-dialog.emptyTitle": "为Pi提供更多上下文", + "files-dialog.emptyDescription": "上传文档、数据或参考资料,帮助Pi给出更好的回答。", + "files-dialog.uploadButtonText": "上传文件", + "files-dialog.emptyHint": "文件存储在您浏览器的本地。", + "files-dialog.filterEmptyTitle": "无匹配文件", + "files-dialog.filterEmptyDescription": "尝试其他筛选条件。", + "files-dialog.closeLabel": "关闭文件", + "files-dialog.title": "文件", + "files-dialog.subtitle": "Pi可用的文档", + "files-dialog.detailBackButton": "返回文件列表", + "files-dialog.uploadButton": "上传", + "files-dialog.connectFolderButton": "连接文件夹", + "files-dialog.placeholder": "筛选文件…", + "files-dialog.storageUnavailable": "存储不可用", + "files-dialog-actions.copyContent": "复制内容", + "files-dialog-actions.download": "下载", + "files-dialog-actions.rename": "重命名", + "files-dialog-actions.delete": "删除", + "files-dialog-actions.cancel": "取消", + "render-csv-table.copied": "已复制!", + "provider.proxy.command": "npx pi-for-excel-proxy", + "files-dialog-actions.open": "打开 ↗", + "status-popovers.thinkingLevel": "思考级别", + "status-popovers.contextUsage": "上下文用量", + "experimental.tip": "提示:使用 /experimental on <功能>、/experimental off <功能>、/experimental toggle <功能>、/experimental tmux-bridge-url 、/experimental tmux-bridge-token 或 /experimental tmux-status。", + "rules.guidance.default": "在所有对话中给Pi的指导。Pi也可以根据您的反馈更新这些内容。", + "status.thinking.aria": "思考级别 {level}", + "status.context.aria": "上下文用量 {pct}% / {label}", + "provider.keyAria": "{label} 的 API 密钥", + "rules.guidance.workbook": "仅在此文件中给Pi的指导。", + "experimental.dark_mode.title": "深色模式", + "experimental.dark_mode.desc": "为任务窗格UI启用由Office/主题驱动的深色模式", + "experimental.remote_urls.title": "远程扩展URL", + "experimental.remote_urls.desc": "允许从远程http(s) URL加载扩展", + "experimental.remote_urls.warning": "不安全:远程扩展代码可读取工作簿数据和凭据", + "experimental.permission_gates.title": "扩展权限门控", + "experimental.permission_gates.desc": "扩展激活时强制实施每个扩展的能力权限", + "experimental.sandbox_rollback.title": "扩展沙盒回滚", + "experimental.sandbox_rollback.desc": "临时将不受信任的扩展路由回主机运行时(紧急关闭)", + "experimental.sandbox_rollback.warning": "仅作为回滚路径使用。默认行为在iframe中运行不受信任的扩展", + "experimental.widget_v2.title": "扩展组件API v2", + "experimental.widget_v2.desc": "启用增量的多组件生命周期API(upsert/remove/clear)并支持确定性定位", + "runtime.error.noApiKey": "提供者 \"{provider}\" 没有可用的 API 密钥。", + "extensions-hub-connections.noApiKey": "无 API 密钥", + "extensions-hub-connections.ready": "就绪", + "status.context.tooltip": "此对话使用了 Pi 多少内存(上下文窗口)。", + "status.context.popoverFallback": "此对话使用了 Pi 多少内存。", + "status.context.strongAction": "使用 /compact 释放空间或 /new 重新开始。", + "status.context.softAction": "建议使用 /compact 释放空间或 /new 重新开始。", + "status.context.full": "上下文已满——下一条消息将失败。", + "status.context.severe": "上下文已用 {pct}%——回复可能变得不可靠。", + "status.context.warning": "上下文已用 {pct}%。", + "status.context.tokens": "{used} / {total} 个令牌", + "status.thinking.offHint": "最快——无推理步骤", + "status.thinking.minimalHint": "快速——轻度推理", + "status.thinking.lowHint": "较快——中等推理", + "status.thinking.mediumHint": "平衡——扎实推理", + "status.thinking.highHint": "较慢——全面推理", + "status.thinking.xhighHint": "最慢——深度推理", + "status-popovers.compactTitle": "压缩对话", + "status-popovers.compactDesc": "摘要较早消息以释放空间。", + "status-popovers.newTitle": "新建聊天", + "status-popovers.newDesc": "打开一个空白上下文的新标签页。", + "hint.explain.label": "解释此工作簿", + "hint.quality.label": "质量检查此工作簿", + "hint.financial.label": "建立财务模型", + "hint.format.label": "格式化此工作表", + "hint.explain.prompt": "通读整个工作簿——每个工作表、结构、公式和命名区域。然后为此工作簿编写清晰的总览和使用手册。\n涵盖:工作簿的用途、组织方式、工作表之间的逻辑流程、输入数据的位置以及输出的推导方式。\n如果是模型:解释关键假设(以及在哪里更改它们)、计算逻辑以及输出如何依赖输入。\n如果是数据:解释数据代表什么、关键字段、任何派生列以及值得注意的模式或空缺。\n像写文档一样组织你的解释——先从摘要开始,然后逐一说明每个工作表的作用。", + "hint.quality.prompt": "审查此工作簿在逻辑、假设和格式方面的错误和问题:\n- 逻辑:断裂或循环引用、公式中的硬编码数字、行/列间不一致的公式模式、工作表之间缺失的链接、#REF或#VALUE错误。\n- 假设:标记任何关键假设(如增长率、折现率、利润率)——它们合理吗?是否清晰标注且易于找到,还是埋藏在公式中?\n- 格式:列内不一致的数字格式、缺失或错位的标题、未标注的输入单元格、不一致的小数位数或货币符号、打破视觉模式的行/列。\n将你的发现按严重性分组,作为优先级列表总结。", + "hint.financial.prompt": "首先通读整个工作簿——每个工作表、结构、公式、命名区域以及任何现有数据。对已有内容及其组织方式形成清晰的认识。\n如果工作簿为空白或基本为空:询问我需要什么样的财务模型——例如DCF、LBO、三表模型、预算、预测或对比——然后从假设开始逐步构建。\n如果有部分完成的模型:解释当前结构、逻辑流程、关键假设以及缺失或不完整的内容。主动提出扩展或完成它。\n如果有数据但没有模型:解释数据代表什么,建议可以从中建模的内容,并主动提出构建。", + "hint.format.prompt": "审查此工作表并根据上下文推断每个单元格的正确格式,然后应用格式,包括:\n- 数字格式(货币、百分比、日期、整数与小数)\n- 字体颜色编码(例如输入项用蓝色,公式用黑色)\n- 输入项、输出项和标题的单元格样式\n- 一致的标题和分区标签\n确保格式一致:例如,如果列中所有其他单元格使用一位小数,则应用相同设置。如果某行为粗体或斜体,则扩展到该行中任何未格式化的单元格。\n格式化后,重新读取工作表并验证你的更改是否正确。", + "status.mode.toast": "{mode}模式。", + "ext-hub-connections.allowExternal": "允许Pi搜索网络和调用外部服务", + "ext-hub-connections.externalToolsEnabled": "外部工具已启用", + "ext-hub-connections.externalToolsDisabled": "外部工具已禁用", + "ext-hub-connections.connectionsEmpty": "已安装的扩展尚未注册任何连接。", + "ext-hub-connections.availability": "可用性", + "ext-hub-connections.enableSession": "为此会话启用", + "ext-hub-connections.enableWorkbook": "为工作簿({label})启用", + "ext-hub-connections.scopeUnavailable": "工作簿作用域不可用", + "ext-hub-connections.noMcpServers": "未配置MCP服务器。\n添加一个以连接外部工具。", + "ext-hub-connections.serverNamePlaceholder": "服务器名称", + "ext-hub-connections.serverUrlPlaceholder": "https://server-url/rpc", + "ext-hub-connections.bearerTokenPlaceholder": "Bearer令牌(可选)", + "ext-hub-connections.addButton": "添加", + "ext-hub-connections.pythonName": "Python桥接", + "ext-hub-connections.pythonDesc": "在本地环境中执行Python代码", + "ext-hub-connections.tmuxName": "tmux桥接", + "ext-hub-connections.tmuxDesc": "通过tmux进行远程shell会话", + "ext-hub-connections.quickSetup": "快速设置", + "ext-hub-connections.bridgeUrl": "桥接URL", + "ext-hub-connections.setupHint": "打开终端·粘贴·按回车·如有提示输入y并回车·保持打开", + "ext-hub-connections.configured": "已配置", + "ext-hub-connections.defaultUrl": "默认URL", + "ext-hub-connections.saveButton": "保存", + "ext-hub-connections.clearButton": "清除", + "ext-hub-connections.testButton": "测试", + "ext-hub-connections.removeButton": "移除", + "ext-hub-connections.badgeEnabled": "已启用", + "ext-hub-connections.badgeDisabled": "已禁用", + "ext-hub-connections.badgeNoToken": "(无)", + "ext-hub-connections.scopeSessionOnly": "仅会话", + "ext-hub-connections.scopeOff": "所有作用域均关闭", + "ext-hub-connections.uninstall": "卸载", + "ext-hub-connections.installTab": "安装", + "ext-hub-connections.extConnections": "扩展连接", + "ext-hub-skills.installed": "已安装", + "ext-hub-skills.noExternalSkills": "未安装外部技能。\n在下方粘贴SKILL.md以添加。", + "ext-hub-skills.install": "安装技能", + "perm.commands.register": "注册命令", + "perm.tools.register": "注册工具", + "perm.agent.read": "读取Agent状态", + "perm.agent.events.read": "读取Agent事件", + "perm.ui.overlay": "显示覆盖层", + "perm.ui.widget": "显示小部件", + "perm.ui.toast": "显示提示消息", + "perm.llm.complete": "调用LLM完成", + "perm.http.fetch": "获取外部HTTP资源", + "perm.storage.readwrite": "读写扩展存储", + "perm.connections.readwrite": "管理连接定义和密钥", + "perm.connections.secrets.read": "读取原始连接密钥值", + "perm.clipboard.write": "写入剪贴板文本", + "perm.agent.context.write": "注入Agent上下文", + "perm.agent.steer": "引导活跃Agent运行", + "perm.agent.followup": "队列Agent跟进消息", + "perm.skills.read": "读取技能目录", + "perm.skills.write": "安装/卸载外部技能", + "perm.download.file": "触发文件下载", + "perm.trust.builtin": "内置", + "perm.trust.local-module": "本地模块", + "perm.trust.inline-code": "内联代码", + "perm.trust.remote-url": "远程URL", + "ext-hub-plugins.installed": "已安装", + "ext-hub-plugins.commands": "命令", + "ext-hub-plugins.permissions": "权限", + "ext-hub-plugins.install": "安装", + "ext-hub-plugins.pasteUrl": "粘贴插件URL…", + "ext-hub-plugins.installButton": "安装", + "ext-hub-plugins.uninstall": "卸载", + "ext-hub-plugins.empty": "未安装插件。\nPi可以构建插件,或从URL安装。", + "ext-hub-skills.statusLine": "{active}个技能已激活({bundled}个内置,{external}个外部)", + "files-dialog-filtering.sectionBuiltinDocs": "内置文档", + "files-dialog-filtering.connectFolder": "连接文件夹", + "files-backend.sandboxedWorkspace": "沙盒工作区", + "files-dialog.sandboxed": "沙盒工作区", + "files-dialog.fileCount": "{count} 个文件", + "files-dialog.toast.connectFolderFailed": "连接文件夹失败:{error}", + "files-dialog.toast.refreshFailed": "无法刷新文件:{error}", + "files-dialog.toast.uploadFailed": "上传失败:{error}", + "files-dialog.toast.loadFailed": "无法加载文件:{error}", + "shortcuts.section.chat": "聊天", + "shortcuts.section.tabs": "标签页", + "shortcuts.section.navigation": "导航", + "shortcuts.section.system": "系统", + "resume.sessionMeta": "{count} 条消息 · {date}", + "resume.recentlyClosedMeta": "于 {date} 关闭 · 在新标签页中重新打开", + "resume.defaultActionHint": "默认操作:{action}", + "rules.charsCount": "{chars} / {limit} 字符", + "date.justNow": "刚刚", + "date.minutesAgo": "{n}分钟前", + "date.hoursAgo": "{n}小时前", + "date.daysAgo": "{n}天前", + "resume.crossWorkbookReplace": "此会话是为其他工作簿创建的。仍然恢复并替换当前聊天?", + "resume.crossWorkbookNewTab": "此会话是为其他工作簿创建的。仍然在新标签页中恢复?", + "shortcuts.keys.enterStreaming": "Enter(流式输出时)", + "settings.proxy.disabled": "代理已禁用。", + "settings.proxy.connected": "代理已连接到 {url}", + "custom-gateway.configLabelName": "名称", + "custom-gateway.configLabelEndpoint": "端点", + "custom-gateway.configLabelModel": "模型", + "custom-gateway.configLabelContextWindow": "最大上下文令牌数", + "custom-gateway.configLabelApiKey": "API密钥", + "custom-gateway.apiKeyPlaceholder": "API密钥(本地服务器可选)", + "custom-gateway.contextWindowHint": "用于Pi的本地上下文预算和自动压缩。请设置为你的网关模型的实际上下文窗口。", + "custom-gateway.gatewayEndpoint": "端点:{url}", + "custom-gateway.gatewayModel": "模型:{id}", + "custom-gateway.gatewayContextWindow": "最大上下文:{tokens}", + "custom-gateway.gatewayApiKeyConfigured": "API密钥:已配置", + "custom-gateway.gatewayApiKeyNone": "API密钥:无", + "custom-gateway.deletedGateway": "已删除网关 {name}。", + "custom-gateway.updatedGateway": "已更新网关 {name}。", + "custom-gateway.savedGateway": "已保存网关 {name}。", + "workbook.currentWorkbook": "当前工作簿", + "init.undo": "撤销", + "init.closedTab": "已关闭 {title}", + "init.closedOtherTabs": "已关闭其他 {count} 个标签页", + "compat.thought": "思考", + "init.llmError": "LLM 错误:{error}", + "tools.action.overview": "概览", + "tools.workbookOverview": "工作簿概览" +} \ No newline at end of file diff --git a/src/taskpane.html b/src/taskpane.html index 282194ed..72b1995d 100644 --- a/src/taskpane.html +++ b/src/taskpane.html @@ -1,5 +1,5 @@ - + diff --git a/src/taskpane/bootstrap.ts b/src/taskpane/bootstrap.ts index 18b3ce64..5772cf5c 100644 --- a/src/taskpane/bootstrap.ts +++ b/src/taskpane/bootstrap.ts @@ -14,6 +14,7 @@ import { installModelSelectorPatch } from "../compat/model-selector-patch.js"; import { installProcessEnvShim } from "../compat/process-env-shim.js"; import { renderLoading, renderError } from "../ui/loading.js"; import { getErrorMessage } from "../utils/errors.js"; +import { t } from "../language/index.js"; import { initTaskpane } from "./init.js"; @@ -60,7 +61,7 @@ export function bootstrapTaskpane(): void { const slowInitTimer = setTimeout(() => { if (initComplete) return; - console.warn("[pi] Taskpane initialization is taking longer than expected (>12s)"); + console.warn(t("bootstrap.initTimeoutWarning")); }, 12_000); const hardTimeoutTimer = setTimeout(() => { @@ -68,7 +69,7 @@ export function bootstrapTaskpane(): void { loadingRoot.innerHTML = ""; showFatalError( errorRoot, - "Failed to initialize: Taskpane initialization timed out after 60000ms", + t("bootstrap.fatalTimeout"), ); console.error("[pi] Init error: Taskpane initialization timed out after 60000ms"); }, 60_000); @@ -88,13 +89,13 @@ export function bootstrapTaskpane(): void { clearTimeout(slowInitTimer); clearTimeout(hardTimeoutTimer); loadingRoot.innerHTML = ""; - showFatalError(errorRoot, `Failed to initialize: ${getErrorMessage(error)}`); + showFatalError(errorRoot, t("bootstrap.fatalError", { msg: getErrorMessage(error) })); console.error("[pi] Init error:", error); }); }; if (typeof Office === "undefined") { - console.warn("[pi] Office.js is unavailable — initializing without Excel"); + console.warn(t("bootstrap.officeUnavailable")); runInit(); return; } diff --git a/src/taskpane/init.ts b/src/taskpane/init.ts index b1c0b66e..ac53f848 100644 --- a/src/taskpane/init.ts +++ b/src/taskpane/init.ts @@ -108,6 +108,7 @@ import { import { loadDiscoverableAgentSkillsFromWorkspace } from "../skills/external-store.js"; import { PI_SKILLS_CHANGED_EVENT } from "../skills/events.js"; import { createSkillReadCache } from "../skills/read-cache.js"; +import { initLanguage, t } from "../language/index.js"; import { initAppStorage } from "../storage/init-app-storage.js"; import { renderError } from "../ui/loading.js"; import { showFilesWorkspaceDialog } from "../ui/files-dialog.js"; @@ -152,7 +153,7 @@ import { injectStatusBar } from "./status-bar.js"; import { parseStatusContextWarningSeverity, STATUS_CONTEXT_DESC_ATTR, - STATUS_CONTEXT_POPOVER_FALLBACK_DESCRIPTION, + getStatusContextPopoverFallbackDescription, STATUS_CONTEXT_TOKENS_ATTR, STATUS_CONTEXT_WARNING_ATTR, STATUS_CONTEXT_WARNING_SEVERITY_ATTR, @@ -217,6 +218,14 @@ export async function initTaskpane(opts: { // 1. Storage const { providerKeys, sessions, settings, customProviders } = initAppStorage(); + // Initialize language from storage + try { + const lang = await settings.get("language"); + initLanguage(lang || "en"); + } catch { + initLanguage("en"); + } + // Seed a predictable proxy default for OAuth flows. await ensureDefaultProxyUrl(settings); @@ -252,7 +261,7 @@ export async function initTaskpane(opts: { proxyUrl.trim().length > 0 && !isLoopbackProxyUrl(proxyUrl) ) { - showToast("Security warning: proxy URL is not localhost — it can see your tokens and prompts."); + showToast(t("init.securityWarning")); } } catch { // ignore @@ -382,20 +391,20 @@ export async function initTaskpane(opts: { const sidebar = new PiSidebar(); sidebar.emptyHints = [ { - label: "Explain this workbook", - prompt: "Read through the entire workbook — every sheet, its structure, formulas, and named ranges. Then write a clear overview and user manual for this workbook.\nCover: what the workbook does, how it's organized, the logic flow between sheets, where inputs live and where outputs are derived.\nIf it's a model: explain the key assumptions (and where to change them), the calculation logic, and how outputs depend on inputs.\nIf it's data: explain what the data represents, the key fields, any derived columns, and notable patterns or gaps.\nStructure your explanation like documentation — start with a summary, then walk through each sheet's role.", + label: t("hint.explain.label"), + prompt: t("hint.explain.prompt"), }, { - label: "Quality check this workbook", - prompt: "Review this workbook for errors and issues across logic, assumptions, and formatting:\n- Logic: broken or circular references, hardcoded numbers inside formulas, inconsistent formula patterns across rows/columns, missing links between sheets, #REF or #VALUE errors.\n- Assumptions: flag any key assumptions (e.g. growth rates, discount rates, margins) — are they reasonable? Are they clearly labelled and easy to find, or buried in formulas?\n- Formatting: inconsistent number formats within columns, missing or misaligned headers, unlabelled input cells, inconsistent decimal places or currency symbols, rows/columns that break the visual pattern.\nSummarize your findings as a prioritized list of recommendations, grouped by severity.", + label: t("hint.quality.label"), + prompt: t("hint.quality.prompt"), }, { - label: "Build my financial model", - prompt: "First, read through the entire workbook — every sheet, its structure, formulas, named ranges, and any existing data. Form a clear picture of what's already here and how it's organized.\nIf the workbook is blank or mostly empty: ask me what kind of financial model I need — for example a DCF, LBO, three-statement model, budget, forecast, or comparison — then build it step by step, starting with the assumptions.\nIf there's a partially complete model: explain the current structure, the logic flow, key assumptions, and what's missing or incomplete. Offer to extend or finish it.\nIf there's data but no model: explain what the data represents, suggest what could be modelled from it, and offer to build it.", + label: t("hint.financial.label"), + prompt: t("hint.financial.prompt"), }, { - label: "Format this sheet", - prompt: "Review this worksheet and infer the correct format for each cell from context, then apply formatting including:\n- Number formats (currency, percentages, dates, integers vs. decimals)\n- Font colour coding (e.g. blue for inputs, black for formulas)\n- Cell styles for inputs, outputs, and headers\n- Consistent headers and section labels\nEnsure formats are consistent: for example, if all other cells in a column use one decimal place, apply the same. If a row is bold or italicised, extend that to any unformatted cells in the row.\nAfter formatting, read back the sheet and verify your changes look correct.", + label: t("hint.format.label"), + prompt: t("hint.format.prompt"), }, ]; @@ -1093,7 +1102,7 @@ export async function initTaskpane(opts: { `Network error (likely CORS). If you're using OAuth, enable /settings → Proxy with ${DEFAULT_LOCAL_PROXY_URL} and retry. Guide: ${PROXY_HELPER_DOCS_URL}`, ); } else { - showErrorBanner(errorRoot, `LLM error: ${err}`); + showErrorBanner(errorRoot, t("init.llmError", { error: err })); } } } else { @@ -1191,13 +1200,13 @@ export async function initTaskpane(opts: { const replaceActiveRuntimeSession = async (sessionData: SessionData): Promise => { const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } const busy = activeRuntime.agent.state.isStreaming || activeRuntime.actionQueue.isBusy(); if (busy) { - showToast("Current tab is busy — use open in new tab or wait for it to finish"); + showToast(t("init.currentTabBusy")); return; } @@ -1246,7 +1255,7 @@ export async function initTaskpane(opts: { try { const sessionData = await sessions.loadSession(item.sessionId); if (!sessionData) { - showToast("Couldn't reopen session"); + showToast(t("init.couldNotReopenSession")); return "missing"; } @@ -1254,7 +1263,7 @@ export async function initTaskpane(opts: { showToast(`Reopened: ${formatSessionTitle(item.title)}`); return "reopened"; } catch { - showToast("Couldn't reopen session"); + showToast(t("init.couldNotReopenSession")); return "failed"; } }; @@ -1262,7 +1271,7 @@ export async function initTaskpane(opts: { const reopenRecentlyClosedById = async (recentlyClosedId: string): Promise => { const item = recentlyClosed.removeById(recentlyClosedId); if (!item) { - showToast("Session is no longer in recently closed"); + showToast(t("init.sessionNotInRecentlyClosed")); return false; } @@ -1277,7 +1286,7 @@ export async function initTaskpane(opts: { const reopenLastClosed = async (): Promise => { const item = recentlyClosed.popMostRecent(); if (!item) { - showToast("No recently closed tab"); + showToast(t("init.noRecentlyClosedTab")); return; } @@ -1292,7 +1301,7 @@ export async function initTaskpane(opts: { const checkpoint = latest[0]; if (!checkpoint) { - showToast("No backups for this workbook yet"); + showToast(t("init.noBackupsYet")); return; } @@ -1342,7 +1351,7 @@ export async function initTaskpane(opts: { optsForClose?: { showUndoToast?: boolean }, ): Promise => { if (runtimeManager.listRuntimes().length <= 1) { - showToast("Can't close the last tab"); + showToast(t("init.cantCloseLastTab")); return false; } @@ -1350,7 +1359,7 @@ export async function initTaskpane(opts: { if (!runtime) return false; if (runtime.lockState === "holding_lock") { - showToast("Wait for workbook changes to finish before closing this tab"); + showToast(t("init.waitForChangesBeforeClose")); return false; } @@ -1387,8 +1396,8 @@ export async function initTaskpane(opts: { const showUndoToast = optsForClose?.showUndoToast !== false; if (showUndoToast) { showActionToast({ - message: `Closed ${closedItem.title}`, - actionLabel: "Undo", + message: t("init.closedTab", { title: closedItem.title }), + actionLabel: t("init.undo"), duration: 9000, onAction: () => { void reopenRecentlyClosedById(closedItem.id); @@ -1402,12 +1411,12 @@ export async function initTaskpane(opts: { const renameRuntimeTab = async (runtimeId: string): Promise => { const runtime = runtimeManager.getRuntime(runtimeId); if (!runtime) { - showToast("Session not found"); + showToast(t("init.sessionNotFound")); return; } if (runtime.agent.state.isStreaming || runtime.actionQueue.isBusy()) { - showToast("Wait for this tab to finish before renaming"); + showToast(t("init.waitBeforeRenaming")); return; } @@ -1428,7 +1437,7 @@ export async function initTaskpane(opts: { document.dispatchEvent(new CustomEvent("pi:status-update")); if (nextTitle.length === 0) { - showToast("Tab name reset"); + showToast(t("init.tabNameReset")); return; } @@ -1471,12 +1480,12 @@ export async function initTaskpane(opts: { const duplicateRuntimeTab = async (runtimeId: string): Promise => { const sourceRuntime = runtimeManager.getRuntime(runtimeId); if (!sourceRuntime) { - showToast("Session not found"); + showToast(t("init.sessionNotFound")); return; } if (sourceRuntime.agent.state.isStreaming || sourceRuntime.actionQueue.isBusy()) { - showToast("Wait for this tab to finish before duplicating"); + showToast(t("init.waitBeforeDuplicating")); return; } @@ -1498,7 +1507,7 @@ export async function initTaskpane(opts: { .map((tab) => tab.runtimeId); if (tabsToClose.length === 0) { - showToast("No other tabs"); + showToast(t("init.noOtherTabs")); return; } @@ -1513,11 +1522,11 @@ export async function initTaskpane(opts: { runtimeManager.switchRuntime(runtimeId); if (closedCount === 0) { - showToast("No tabs were closed"); + showToast(t("init.noTabsWereClosed")); return; } - showToast(`Closed ${closedCount} other tab${closedCount === 1 ? "" : "s"}`); + showToast(t("init.closedOtherTabs", { count: closedCount })); }; const moveRuntimeTab = (runtimeId: string, direction: -1 | 1): void => { @@ -1612,7 +1621,7 @@ export async function initTaskpane(opts: { const applyModelSelection = async (runtimeId: string, nextModel: RuntimeModel): Promise => { const runtime = runtimeManager.getRuntime(runtimeId); if (!runtime) { - showToast("Session not found"); + showToast(t("init.sessionNotFound")); return; } @@ -1622,7 +1631,7 @@ export async function initTaskpane(opts: { } if (runtime.agent.state.isStreaming || runtime.actionQueue.isBusy()) { - showToast("Wait for this tab to finish before changing models"); + showToast(t("init.waitBeforeChangingModels")); return; } @@ -1652,7 +1661,7 @@ export async function initTaskpane(opts: { const openModelSelector = (): void => { const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } @@ -1680,7 +1689,7 @@ export async function initTaskpane(opts: { renameActiveSession: async (title: string) => { const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } @@ -1736,7 +1745,7 @@ export async function initTaskpane(opts: { const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } @@ -1760,7 +1769,7 @@ export async function initTaskpane(opts: { } if (result === "missing-queue") { - showToast("No active session"); + showToast(t("init.noActiveSession")); } }; document.addEventListener("pi:command-run", onCommandRun); @@ -1769,7 +1778,7 @@ export async function initTaskpane(opts: { clearErrorBanner(errorRoot); const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } activeRuntime.actionQueue.enqueuePrompt(text); @@ -1828,7 +1837,7 @@ export async function initTaskpane(opts: { }) .then((count) => { if (count <= 0) { - showToast("No files were imported."); + showToast(t("init.noFilesImported")); return; } @@ -1927,7 +1936,7 @@ export async function initTaskpane(opts: { onCloseActiveTab: () => { const activeRuntime = getActiveRuntime(); if (!activeRuntime) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } @@ -1989,7 +1998,7 @@ export async function initTaskpane(opts: { const activeAgent = getActiveAgent(); if (!activeAgent) { - showToast("No active session"); + showToast(t("init.noActiveSession")); return; } @@ -2013,7 +2022,7 @@ export async function initTaskpane(opts: { if (!trigger) return; const description = trigger.getAttribute(STATUS_CONTEXT_DESC_ATTR) - ?? STATUS_CONTEXT_POPOVER_FALLBACK_DESCRIPTION; + ?? getStatusContextPopoverFallbackDescription(); const tokenDetail = trigger.getAttribute(STATUS_CONTEXT_TOKENS_ATTR) ?? undefined; diff --git a/src/taskpane/keyboard-shortcuts.ts b/src/taskpane/keyboard-shortcuts.ts index 403686be..3da8a549 100644 --- a/src/taskpane/keyboard-shortcuts.ts +++ b/src/taskpane/keyboard-shortcuts.ts @@ -10,6 +10,7 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import type { PiSidebar } from "../ui/pi-sidebar.js"; import { moveCursorToEnd } from "../ui/input-focus.js"; import { isActionToastVisible, showToast } from "../ui/toast.js"; +import { t } from "../language/index.js"; import { doesUiClaimStreamingEscape } from "../utils/escape-guard.js"; import { blurTextEntryTarget, isTextEntryTarget } from "../utils/text-entry.js"; @@ -332,7 +333,7 @@ export function installKeyboardShortcuts(opts: { }); if (restoredCount === 0) { - showToast("No queued messages to restore"); + showToast(t("keyboard-shortcuts.toast.noQueuedMessages")); } else { showToast(`Restored ${restoredCount} queued message${restoredCount === 1 ? "" : "s"} to editor`); } diff --git a/src/taskpane/keyboard-shortcuts/editor-actions.ts b/src/taskpane/keyboard-shortcuts/editor-actions.ts index 185ea209..2c49acdf 100644 --- a/src/taskpane/keyboard-shortcuts/editor-actions.ts +++ b/src/taskpane/keyboard-shortcuts/editor-actions.ts @@ -6,6 +6,7 @@ import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core"; import type { PiSidebar } from "../../ui/pi-sidebar.js"; import { showToast } from "../../ui/toast.js"; +import { t } from "../../language/index.js"; import { hideCommandMenu } from "../../commands/command-menu.js"; import { executeSlashCommand } from "../../commands/slash-command-execution.js"; @@ -180,7 +181,7 @@ export function handleSlashCommandExecution(args: { } if (result === "missing-queue") { - showToast("No active session"); + showToast(t("editor-actions.toast.noActiveSession")); return true; } diff --git a/src/taskpane/queue-display.ts b/src/taskpane/queue-display.ts index b2720511..82e8158e 100644 --- a/src/taskpane/queue-display.ts +++ b/src/taskpane/queue-display.ts @@ -10,6 +10,7 @@ import type { Agent } from "@earendil-works/pi-agent-core"; import type { PiSidebar } from "../ui/pi-sidebar.js"; import { extractTextFromContent } from "../utils/content.js"; +import { t } from "../language/index.js"; export type QueuedMessageType = "steer" | "follow-up"; export type QueuedActionType = "prompt" | "command"; @@ -35,7 +36,7 @@ function renderQueuedItem({ type, text }: QueuedMessageItem): HTMLElement { const itemEl = document.createElement("div"); itemEl.className = "pi-queue__item"; - const label = type === "steer" ? "Steering" : "Follow-up"; + const label = type === "steer" ? t("queue-display.steerLabel") : t("queue-display.followUpLabel"); const cls = type === "steer" ? "pi-queue__label--steer" : "pi-queue__label--followup"; const labelEl = document.createElement("span"); @@ -127,7 +128,7 @@ export function createQueueDisplay(opts: { const hintEl = document.createElement("div"); hintEl.className = "pi-queue__hint"; - hintEl.textContent = `↳ ${getQueuedRestoreShortcutHint()} to edit queued messages`; + hintEl.textContent = t("queue-display.shortcutHint", { shortcut: getQueuedRestoreShortcutHint() }); fragment.appendChild(hintEl); container.replaceChildren(fragment); diff --git a/src/taskpane/session-title.ts b/src/taskpane/session-title.ts index c2770962..a5b310aa 100644 --- a/src/taskpane/session-title.ts +++ b/src/taskpane/session-title.ts @@ -2,6 +2,8 @@ * Session title helpers for tab labels and close/reopen toasts. */ +import { t } from "../language/index.js"; + export interface ResolveTabTitleArgs { hasExplicitTitle: boolean; sessionTitle: string; @@ -25,5 +27,5 @@ export function resolveTabTitle(args: ResolveTabTitleArgs): string { } } - return `Chat ${normalizeTabNumber(args.defaultTabNumber)}`; + return t("session.tab.default", { number: String(normalizeTabNumber(args.defaultTabNumber)) }); } diff --git a/src/taskpane/status-bar.ts b/src/taskpane/status-bar.ts index c7514b86..aae9c937 100644 --- a/src/taskpane/status-bar.ts +++ b/src/taskpane/status-bar.ts @@ -4,6 +4,7 @@ import type { Agent } from "@earendil-works/pi-agent-core"; +import { t } from "../language/index.js"; import { showToast } from "../ui/toast.js"; import { escapeAttr, escapeHtml } from "../utils/html.js"; import { formatUsageDebug, isDebugEnabled } from "../debug/debug.js"; @@ -13,7 +14,7 @@ import { getStatusContextHealth, STATUS_CONTEXT_DESC_ATTR, STATUS_CONTEXT_TOKENS_ATTR, - STATUS_CONTEXT_TOOLTIP_DESCRIPTION, + getStatusContextTooltipDescription, STATUS_CONTEXT_WARNING_ATTR, STATUS_CONTEXT_WARNING_SEVERITY_ATTR, } from "./status-context.js"; @@ -58,7 +59,7 @@ function renderStatusBar( if (!el) return; if (!agent) { - const emptyMarkup = `No active session`; + const emptyMarkup = `${t("status.no_session")}`; const emptySignature = "no-agent"; if (el.getAttribute("data-status-signature") !== emptySignature) { el.innerHTML = emptyMarkup; @@ -71,7 +72,7 @@ function renderStatusBar( // Model alias const model = state.model; - const modelAlias = model ? (model.name || model.id) : "Select model"; + const modelAlias = model ? (model.name || model.id) : t("status.select_model"); const modelAliasEscaped = escapeHtml(modelAlias); // Context usage @@ -91,13 +92,13 @@ function renderStatusBar( // Thinking level const thinkingLabels: Record = { - off: "off", minimal: "min", low: "low", medium: "med", high: "high", xhigh: "max", + off: t("status.thinking.off"), minimal: t("status.thinking.min"), low: t("status.thinking.low"), medium: t("status.thinking.medium"), high: t("status.thinking.high"), xhigh: t("status.thinking.max"), }; const thinkingLevel = thinkingLabels[state.thinkingLevel] || state.thinkingLevel; // Context health: color + tooltip based on usage - const ctxDescription = STATUS_CONTEXT_TOOLTIP_DESCRIPTION; - const ctxTokenDetail = `${totalTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens`; + const ctxDescription = getStatusContextTooltipDescription(); + const ctxTokenDetail = t("status.context.tokens", { used: totalTokens.toLocaleString(), total: contextWindow.toLocaleString() }); const contextHealth = getStatusContextHealth(pct); const ctxColor = contextHealth.colorClass; @@ -125,14 +126,14 @@ function renderStatusBar( const modeIsAuto = executionMode === "yolo"; const modeBadgeClass = modeIsAuto ? " pi-status-mode--auto" : " pi-status-mode--confirm"; - const modeLabel = modeIsAuto ? "auto" : "confirm"; + const modeLabel = modeIsAuto ? t("status.mode.auto") : t("status.mode.confirm"); const modeTooltip = modeIsAuto - ? "Auto: Pi applies workbook changes immediately. Click to switch to Confirm." - : "Confirm: Pi asks before each workbook change. Click to switch to Auto."; + ? t("status.mode.auto.tooltip") + : t("status.mode.confirm.tooltip"); const modeBadge = ``; const thinkingTooltip = escapeAttr( - "How deeply Pi reasons before answering — higher is slower but more thorough. Click to choose, or ⇧Tab to cycle.", + t("status.thinking.tooltip"), ); const ctxPopoverDesc = escapeAttr(ctxDescription); @@ -141,13 +142,13 @@ function renderStatusBar( const nextMarkup = `
- - - + + ${lockBadge}
diff --git a/src/taskpane/status-context.ts b/src/taskpane/status-context.ts index 6f71400a..a2538beb 100644 --- a/src/taskpane/status-context.ts +++ b/src/taskpane/status-context.ts @@ -1,10 +1,16 @@ +import { t } from "../language/index.js"; + export const STATUS_CONTEXT_DESC_ATTR = "data-ctx-desc"; export const STATUS_CONTEXT_TOKENS_ATTR = "data-ctx-tokens"; export const STATUS_CONTEXT_WARNING_ATTR = "data-ctx-warn"; export const STATUS_CONTEXT_WARNING_SEVERITY_ATTR = "data-ctx-severity"; -export const STATUS_CONTEXT_TOOLTIP_DESCRIPTION = "How much of Pi's memory (context window) this conversation is using."; -export const STATUS_CONTEXT_POPOVER_FALLBACK_DESCRIPTION = "How much of Pi's memory this conversation is using."; +export function getStatusContextTooltipDescription(): string { + return t("status.context.tooltip"); +} +export function getStatusContextPopoverFallbackDescription(): string { + return t("status.context.popoverFallback"); +} export type StatusContextWarningSeverity = "yellow" | "red"; @@ -21,17 +27,21 @@ export interface StatusContextHealth { warning: StatusContextWarning | null; } -const STRONG_ACTION_TEXT = "Use /compact to free space or /new to start fresh."; -const SOFT_ACTION_TEXT = "Consider using /compact to free space or /new to start fresh."; +function getStrongActionText(): string { + return t("status.context.strongAction"); +} +function getSoftActionText(): string { + return t("status.context.softAction"); +} export function getStatusContextHealth(pct: number): StatusContextHealth { if (pct > 100) { return { colorClass: "pi-status-ctx--red", warning: { - text: "Context is full — the next message will fail.", + text: t("status.context.full"), severity: "red", - actionText: STRONG_ACTION_TEXT, + actionText: getStrongActionText(), }, }; } @@ -40,9 +50,9 @@ export function getStatusContextHealth(pct: number): StatusContextHealth { return { colorClass: "pi-status-ctx--red", warning: { - text: `Context ${pct}% full — responses may become less reliable.`, + text: t("status.context.severe", { pct }), severity: "red", - actionText: STRONG_ACTION_TEXT, + actionText: getStrongActionText(), }, }; } @@ -51,9 +61,9 @@ export function getStatusContextHealth(pct: number): StatusContextHealth { return { colorClass: "pi-status-ctx--yellow", warning: { - text: `Context ${pct}% full.`, + text: t("status.context.warning", { pct }), severity: "yellow", - actionText: SOFT_ACTION_TEXT, + actionText: getSoftActionText(), }, }; } diff --git a/src/taskpane/status-popovers.ts b/src/taskpane/status-popovers.ts index d63d62f8..787ac2de 100644 --- a/src/taskpane/status-popovers.ts +++ b/src/taskpane/status-popovers.ts @@ -3,6 +3,7 @@ */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { t } from "../language/index.js"; import type { StatusContextWarningSeverity } from "./status-context.js"; @@ -33,23 +34,27 @@ interface ContextPopoverOptions { onRunCommand: (command: StatusCommandName) => void; } -const THINKING_LEVEL_LABELS: Record = { - off: "Off", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Max", -}; - -const THINKING_LEVEL_HINTS: Record = { - off: "Fastest — no reasoning step", - minimal: "Quick — light reasoning", - low: "Fast — moderate reasoning", - medium: "Balanced — solid reasoning", - high: "Slow — thorough reasoning", - xhigh: "Slowest — deepest reasoning", -}; +function getThinkingLevelLabels(): Record { + return { + off: t("status.thinking.off"), + minimal: t("status.thinking.min"), + low: t("status.thinking.low"), + medium: t("status.thinking.medium"), + high: t("status.thinking.high"), + xhigh: t("status.thinking.max"), + }; +} + +function getThinkingLevelHints(): Record { + return { + off: t("status.thinking.offHint"), + minimal: t("status.thinking.minimalHint"), + low: t("status.thinking.lowHint"), + medium: t("status.thinking.mediumHint"), + high: t("status.thinking.highHint"), + xhigh: t("status.thinking.xhighHint"), + }; +} let activePopover: ActivePopoverState | null = null; @@ -183,7 +188,7 @@ export function toggleThinkingPopover(opts: ThinkingPopoverOptions): void { const title = document.createElement("h3"); title.className = "pi-status-popover__title"; - title.textContent = "Thinking level"; + title.textContent = t("status-popovers.thinkingLevel"); const description = createDescriptionBlock(opts.description); @@ -203,11 +208,11 @@ export function toggleThinkingPopover(opts: ThinkingPopoverOptions): void { const label = document.createElement("span"); label.className = "pi-status-popover__item-label"; - label.textContent = THINKING_LEVEL_LABELS[level]; + label.textContent = getThinkingLevelLabels()[level]; const hint = document.createElement("span"); hint.className = "pi-status-popover__item-hint"; - hint.textContent = THINKING_LEVEL_HINTS[level]; + hint.textContent = getThinkingLevelHints()[level]; body.append(label, hint); @@ -294,14 +299,14 @@ export function toggleContextPopover(opts: ContextPopoverOptions): void { actions.append( createCommandButton({ command: "compact", - title: "Compact conversation", - description: "Summarize earlier messages to free space.", + title: t("status-popovers.compactTitle"), + description: t("status-popovers.compactDesc"), onRun: opts.onRunCommand, }), createCommandButton({ command: "new", - title: "Start new chat", - description: "Open a fresh tab with empty context.", + title: t("status-popovers.newTitle"), + description: t("status-popovers.newDesc"), onRun: opts.onRunCommand, }), ); diff --git a/src/taskpane/welcome-login.ts b/src/taskpane/welcome-login.ts index ce132817..16648d86 100644 --- a/src/taskpane/welcome-login.ts +++ b/src/taskpane/welcome-login.ts @@ -2,6 +2,8 @@ * Welcome/login overlay shown when no providers are configured. */ +import { t, initLanguage, getLanguage } from "../language/index.js"; + import type { ProviderKeysStore } from "@earendil-works/pi-web-ui/dist/storage/stores/provider-keys-store.js"; import { getAppStorage } from "@earendil-works/pi-web-ui/dist/storage/app-storage.js"; @@ -85,27 +87,27 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const title = createElement("h2", "pi-welcome-title"); title.id = titleId; - title.textContent = "Pi for Excel"; + title.textContent = t("welcome.title"); const subtitle = createElement("p", "pi-welcome-subtitle"); subtitle.id = subtitleId; - subtitle.textContent = "Connect an AI provider to get started"; + subtitle.textContent = t("welcome.subtitle"); const intro = createElement("p", "pi-welcome-intro"); - intro.textContent = "An AI agent that reads your spreadsheet, makes changes, and does the research — using models you already have."; + intro.textContent = t("welcome.intro"); const providerSectionTitle = createElement("p", "pi-welcome-section-title"); - providerSectionTitle.textContent = "Choose a provider"; + providerSectionTitle.textContent = t("welcome.select_provider"); const providerList = createElement("div", "pi-welcome-providers"); const customGatewayButton = createElement("button", "pi-welcome-custom-gateway"); customGatewayButton.type = "button"; - customGatewayButton.textContent = "Use a custom OpenAI-compatible gateway"; + customGatewayButton.textContent = t("welcome.custom_gateway"); const proxyToggle = createElement("button", "pi-welcome-proxy-toggle"); proxyToggle.type = "button"; - proxyToggle.textContent = "Having login trouble? Configure local proxy"; + proxyToggle.textContent = t("welcome.proxy.toggle_show"); proxyToggle.setAttribute("aria-expanded", "false"); const proxyPanel = createElement("section", "pi-welcome-proxy"); @@ -114,13 +116,13 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const proxyTopRow = createElement("div", "pi-welcome-proxy__row"); const proxyTitle = createElement("div", "pi-welcome-proxy__title"); - proxyTitle.textContent = "Local HTTPS proxy"; + proxyTitle.textContent = t("welcome.proxy.title"); const proxyToggleLabel = createElement("label", "pi-welcome-proxy__toggle"); const proxyEnabledEl = createElement("input", "pi-welcome-proxy__enabled"); proxyEnabledEl.type = "checkbox"; const proxyToggleText = createElement("span"); - proxyToggleText.textContent = "Enabled"; + proxyToggleText.textContent = t("welcome.proxy.enabled"); proxyToggleLabel.append(proxyEnabledEl, proxyToggleText); proxyTopRow.append(proxyTitle, proxyToggleLabel); @@ -132,7 +134,7 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const proxySaveEl = createElement("button", "pi-welcome-proxy__save"); proxySaveEl.type = "button"; - proxySaveEl.textContent = "Save"; + proxySaveEl.textContent = t("welcome.proxy.save"); proxyUrlRow.append(proxyUrlEl, proxySaveEl); @@ -144,19 +146,70 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise proxyGuideLink.href = PROXY_HELPER_DOCS_URL; proxyGuideLink.target = "_blank"; proxyGuideLink.rel = "noopener noreferrer"; - proxyGuideLink.textContent = "Step-by-step guide"; + proxyGuideLink.textContent = t("welcome.proxy.guide"); proxyHint.append( - "Needed only when OAuth login is blocked by CORS. Keep this URL at ", + t("welcome.proxy.hint.prefix"), proxyCode, - ", run a local HTTPS proxy, then enable this toggle. ", + t("welcome.proxy.hint.suffix"), proxyGuideLink, - ".", + t("welcome.proxy.hint.end"), ); proxyPanel.append(proxyTopRow, proxyUrlRow, proxyHint); + + // Language bar at the top + const langBar = createElement("div", "pi-welcome-lang-bar"); + langBar.style.cssText = "display:flex;justify-content:flex-end;gap:4px;padding:4px 8px;"; + + const engBtn = createElement("button"); + engBtn.type = "button"; + engBtn.textContent = "English"; + engBtn.style.cssText = "font-size:11px;padding:2px 8px;border:1px solid #ccc;border-radius:4px;background:var(--pi-bg, #fff);cursor:pointer;"; + + const zhBtn = createElement("button"); + zhBtn.type = "button"; + zhBtn.textContent = "中文"; + zhBtn.style.cssText = "font-size:11px;padding:2px 8px;border:1px solid #ccc;border-radius:4px;background:var(--pi-bg, #fff);cursor:pointer;"; + + const currentLang2 = getLanguage(); + if (currentLang2 === "zh-CN") { + zhBtn.style.borderColor = "var(--color-accent, #3b82f6)"; + zhBtn.style.color = "var(--color-accent, #3b82f6)"; + } else { + engBtn.style.borderColor = "var(--color-accent, #3b82f6)"; + engBtn.style.color = "var(--color-accent, #3b82f6)"; + } + + engBtn.addEventListener("click", () => { + if (getLanguage() === "en") return; + initLanguage("en"); + void (async () => { + try { + const storage = getAppStorage(); + await storage.settings.set("language", "en"); + location.reload(); + } catch { /* ignore */ } + })(); + }); + + zhBtn.addEventListener("click", () => { + if (getLanguage() === "zh-CN") return; + initLanguage("zh-CN"); + void (async () => { + try { + const storage = getAppStorage(); + await storage.settings.set("language", "zh-CN"); + location.reload(); + } catch { /* ignore */ } + })(); + }); + + langBar.append(engBtn, zhBtn); + dialog.card.replaceChildren( + langBar, logo, title, subtitle, @@ -179,7 +232,7 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise void showSettingsDialog({ section: "custom-gateways" }); }) .catch(() => { - showToast("Couldn't open custom gateway settings."); + showToast(t("welcome.toast.cannot_open_settings")); }); }); @@ -188,8 +241,8 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise proxyPanel.hidden = !willOpen; proxyToggle.setAttribute("aria-expanded", willOpen ? "true" : "false"); proxyToggle.textContent = willOpen - ? "Hide local proxy settings" - : "Having login trouble? Configure local proxy"; + ? t("welcome.proxy.toggle_hide") + : t("welcome.proxy.toggle_show"); if (willOpen) { proxyUrlEl.focus(); @@ -214,9 +267,9 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const storage = getAppStorage(); await storage.settings.set("proxy.enabled", proxyEnabledEl.checked); await storage.settings.set("proxy.url", proxyUrlEl.value.trim()); - showToast("Proxy settings saved"); + showToast(t("welcome.toast.proxy_saved")); } catch { - showToast("Failed to save proxy settings"); + showToast(t("welcome.toast.proxy_failed")); } }; @@ -240,7 +293,7 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const updated = await providerKeys.list(); setActiveProviders(new Set(updated)); document.dispatchEvent(new CustomEvent("pi:providers-changed")); - showToast(`${label} connected — try “Explain this workbook”.`, 3200); + showToast(t("welcome.toast.connected", { label }), 3200); closeOverlay(); })(); }, @@ -249,7 +302,7 @@ export async function showWelcomeLogin(providerKeys: ProviderKeysStore): Promise const updated = await providerKeys.list(); setActiveProviders(new Set(updated)); document.dispatchEvent(new CustomEvent("pi:providers-changed")); - showToast(`${label} disconnected`); + showToast(t("welcome.toast.disconnected", { label })); })(); }, }); diff --git a/src/tools/get-workbook-overview.ts b/src/tools/get-workbook-overview.ts index 07cfdfa7..c91b1591 100644 --- a/src/tools/get-workbook-overview.ts +++ b/src/tools/get-workbook-overview.ts @@ -8,9 +8,12 @@ * Pushes rich structural metadata (headers, named ranges, tables) — not just sheet names + dimensions. */ +import { t } from "../language/index.js"; import { Type, type Static } from "@sinclair/typebox"; import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import { t } from "../language/index.js"; import { excelRun, colToLetter } from "../excel/helpers.js"; +import { t } from "../language/index.js"; import { getErrorMessage } from "../utils/errors.js"; const schema = Type.Object({ @@ -29,7 +32,7 @@ type Params = Static; export function createGetWorkbookOverviewTool(): AgentTool { return { name: "get_workbook_overview", - label: "Workbook Overview", + label: t("tools.workbookOverview"), description: "Get a structural overview of the workbook: sheet names, dimensions, " + "header rows, named ranges, tables, and object counts. Use this at the start of a " + diff --git a/src/ui-gallery.html b/src/ui-gallery.html index 76738027..3584d5d5 100644 --- a/src/ui-gallery.html +++ b/src/ui-gallery.html @@ -1,5 +1,5 @@ - + diff --git a/src/ui/bridge-setup-card.ts b/src/ui/bridge-setup-card.ts index 3e8a743c..3f3b57e7 100644 --- a/src/ui/bridge-setup-card.ts +++ b/src/ui/bridge-setup-card.ts @@ -14,6 +14,7 @@ import { type PythonTransformRangeDetails, type TmuxBridgeDetails, } from "../tools/tool-details.js"; +import { t } from "../language/index.js"; import { AlertTriangle, Check, Copy, Terminal, lucide } from "./lucide-icons.js"; export const PYTHON_BRIDGE_SETUP_COMMAND = "npx pi-for-excel-python-bridge"; @@ -66,8 +67,8 @@ function createCopyableCommand(command: string): HTMLDivElement { const copyBtn = document.createElement("button"); copyBtn.type = "button"; copyBtn.className = "pi-bridge-setup__copy"; - copyBtn.title = "Copy command"; - copyBtn.setAttribute("aria-label", "Copy command"); + copyBtn.title = t("bridge-setup.copyCommandTitle"); + copyBtn.setAttribute("aria-label", t("bridge-setup.copyCommandTitle")); copyBtn.replaceChildren(lucide(Copy)); let resetTimeout: ReturnType | null = null; @@ -75,8 +76,8 @@ function createCopyableCommand(command: string): HTMLDivElement { copyBtn.addEventListener("click", () => { copyToClipboard(command, () => { copyBtn.replaceChildren(lucide(Check)); - copyBtn.title = "Copied"; - copyBtn.setAttribute("aria-label", "Copied"); + copyBtn.title = t("bridge-setup.copiedTitle"); + copyBtn.setAttribute("aria-label", t("bridge-setup.copiedTitle")); if (resetTimeout !== null) { clearTimeout(resetTimeout); @@ -84,8 +85,8 @@ function createCopyableCommand(command: string): HTMLDivElement { resetTimeout = setTimeout(() => { copyBtn.replaceChildren(lucide(Copy)); - copyBtn.title = "Copy command"; - copyBtn.setAttribute("aria-label", "Copy command"); + copyBtn.title = t("bridge-setup.copyCommandTitle"); + copyBtn.setAttribute("aria-label", t("bridge-setup.copyCommandTitle")); resetTimeout = null; }, 1400); }, code); @@ -145,7 +146,7 @@ function toTmuxModel(details: TmuxBridgeDetails): BridgeSetupCardModel | null { } return { - title: "Terminal access is not available", + title: t("bridge-setup.tmuxTitle"), command: TMUX_BRIDGE_SETUP_COMMAND, probeUrl: resolveProbeUrl({ bridgeUrl: details.bridgeUrl, @@ -172,7 +173,7 @@ function toPythonModel(details: PythonBridgeDetails): BridgeSetupCardModel | nul } const title = details.error === "no_python_runtime" - ? "Python is unavailable" + ? t("bridge-setup.pythonUnavailable") : "Python bridge is unavailable"; return { @@ -315,11 +316,11 @@ export function mountBridgeSetupCard( const intro = document.createElement("p"); intro.className = "pi-bridge-setup__text"; - intro.textContent = "In a terminal, run:"; + intro.textContent = t("bridge-setup.intro"); const hint = document.createElement("p"); hint.className = "pi-bridge-setup__hint"; - hint.textContent = "Keep it running, then try again."; + hint.textContent = t("bridge-setup.keepRunning"); const actions = document.createElement("div"); actions.className = "pi-bridge-setup__actions"; @@ -327,7 +328,7 @@ export function mountBridgeSetupCard( const testButton = document.createElement("button"); testButton.type = "button"; testButton.className = "pi-bridge-setup__test"; - testButton.textContent = "Test connection"; + testButton.textContent = t("bridge-setup.testConnection"); const status = document.createElement("span"); status.className = "pi-bridge-setup__status"; @@ -338,7 +339,7 @@ export function mountBridgeSetupCard( if (!model.probeUrl) { testButton.disabled = true; - status.textContent = "Set a valid bridge URL first, then test again."; + status.textContent = t("bridge-setup.setValidUrlFirst"); status.className = "pi-bridge-setup__status is-warn"; } @@ -350,29 +351,29 @@ export function mountBridgeSetupCard( checking = true; testButton.disabled = true; - testButton.textContent = "Checking…"; - status.textContent = "Checking bridge…"; + testButton.textContent = t("bridge-setup.checking"); + status.textContent = t("bridge-setup.checkingBridge"); status.className = "pi-bridge-setup__status"; void probeBridge(probeUrl).then( (reachable) => { if (reachable) { - status.textContent = "✓ Bridge detected — ask the assistant to try again."; + status.textContent = t("bridge-setup.bridgeDetected"); status.className = "pi-bridge-setup__status is-ok"; return; } - status.textContent = "Bridge not detected yet — keep terminal open and try again."; + status.textContent = t("bridge-setup.bridgeNotDetected"); status.className = "pi-bridge-setup__status is-warn"; }, () => { - status.textContent = "Could not check bridge status right now."; + status.textContent = t("bridge-setup.cannotCheckBridge"); status.className = "pi-bridge-setup__status is-error"; }, ).finally(() => { checking = false; testButton.disabled = false; - testButton.textContent = "Test connection"; + testButton.textContent = t("bridge-setup.testConnection"); }); }); @@ -384,7 +385,7 @@ export function mountBridgeSetupCard( const dismissButton = document.createElement("button"); dismissButton.type = "button"; dismissButton.className = "pi-bridge-setup__dismiss"; - dismissButton.textContent = "Dismiss"; + dismissButton.textContent = t("bridge-setup.dismiss"); dismissButton.addEventListener("click", () => { card.classList.add("is-dismissed"); setTimeout(() => card.remove(), 200); diff --git a/src/ui/confirm-dialog.ts b/src/ui/confirm-dialog.ts index 28ed4a3c..b5d0b8a7 100644 --- a/src/ui/confirm-dialog.ts +++ b/src/ui/confirm-dialog.ts @@ -5,9 +5,11 @@ import { createOverlayHeader, } from "./overlay-dialog.js"; import { CONFIRM_DIALOG_OVERLAY_ID } from "./overlay-ids.js"; +import { t } from "../language/index.js"; -const CONFIRMATION_UI_UNAVAILABLE_ERROR = - "Confirmation UI is unavailable in this environment."; +function getConfirmationUiUnavailableError(): string { + return t("confirm.unavailable"); +} export type ConfirmButtonTone = "primary" | "danger"; @@ -36,7 +38,7 @@ function getConfirmButtonClassName(tone: ConfirmButtonTone | undefined): string export function requestConfirmationDialog(options: ConfirmDialogOptions): Promise { if (!canRenderConfirmationDialog()) { - return Promise.reject(new Error(CONFIRMATION_UI_UNAVAILABLE_ERROR)); + return Promise.reject(new Error(getConfirmationUiUnavailableError())); } const overlayId = options.overlayId ?? CONFIRM_DIALOG_OVERLAY_ID; @@ -72,7 +74,7 @@ export function requestConfirmationDialog(options: ConfirmDialogOptions): Promis const { header } = createOverlayHeader({ onClose: cancel, - closeLabel: options.cancelLabel ?? "Cancel", + closeLabel: options.cancelLabel ?? t("confirm.cancel"), title: options.title, }); @@ -87,11 +89,11 @@ export function requestConfirmationDialog(options: ConfirmDialogOptions): Promis actions.className = "pi-overlay-actions"; const cancelButton = createOverlayButton({ - text: options.cancelLabel ?? "Cancel", + text: options.cancelLabel ?? t("confirm.cancel"), }); const confirmButton = createOverlayButton({ - text: options.confirmLabel ?? "Confirm", + text: options.confirmLabel ?? t("confirm.confirm"), className: getConfirmButtonClassName(options.confirmButtonTone), }); diff --git a/src/ui/disclosure-bar.ts b/src/ui/disclosure-bar.ts index 1b42ddbe..7ea0c871 100644 --- a/src/ui/disclosure-bar.ts +++ b/src/ui/disclosure-bar.ts @@ -5,6 +5,7 @@ * MCP, skills) and lets them acknowledge or customize before using the agent. */ +import { t } from "../language/index.js"; import { createToggleRow } from "./extensions-hub-components.js"; const ACKNOWLEDGED_KEY = "pi.onboarding.disclosure.acknowledged"; @@ -28,7 +29,7 @@ function setAcknowledged(): void { export interface DisclosureBarOptions { /** Number of configured providers (bar only shows when ≥1). */ providerCount: number; - /** Callback to open Settings overlay. If provided, "Change anytime in Settings" becomes a link. */ + /** Callback to open Settings overlay. If provided, t("disclosure-bar.changeInSettings") becomes a link. */ onOpenSettings?: () => void; } @@ -48,7 +49,7 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement const text = document.createElement("div"); text.className = "pi-disclosure-bar__text"; - text.textContent = "Pi can search the web, use extensions, and connect to external services."; + text.textContent = t("disclosure-bar.text"); bar.appendChild(text); // --- Expandable picker (hidden by default) --- @@ -57,10 +58,10 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement bar.appendChild(picker); const toggleRows: { label: string; sublabel: string }[] = [ - { label: "Web search & page fetch", sublabel: "Search engines and read web pages" }, - { label: "Extensions & plugins", sublabel: "Sidebar tools and custom commands" }, - { label: "External services (MCP)", sublabel: "Connect to tool servers you configure" }, - { label: "Skills", sublabel: "Instruction documents the AI follows" }, + { label: t("disclosure-bar.webSearchLabel"), sublabel: "Search engines and read web pages" }, + { label: t("disclosure-bar.extensionsLabel"), sublabel: "Sidebar tools and custom commands" }, + { label: t("disclosure-bar.externalServicesLabel"), sublabel: "Connect to tool servers you configure" }, + { label: t("disclosure-bar.skillsLabel"), sublabel: "Instruction documents the AI follows" }, ]; for (const row of toggleRows) { @@ -84,13 +85,13 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement const gotItBtn = document.createElement("button"); gotItBtn.className = "pi-overlay-btn pi-overlay-btn--primary pi-overlay-btn--compact"; - gotItBtn.textContent = "Got it"; + gotItBtn.textContent = t("disclosure-bar.gotIt"); gotItBtn.addEventListener("click", dismiss); actions.appendChild(gotItBtn); const customizeBtn = document.createElement("button"); customizeBtn.className = "pi-disclosure-bar__link"; - customizeBtn.textContent = "Customize"; + customizeBtn.textContent = t("disclosure-bar.customize"); actions.appendChild(customizeBtn); let hint: HTMLElement; @@ -98,7 +99,7 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement const link = document.createElement("button"); link.type = "button"; link.className = "pi-disclosure-bar__settings-link"; - link.textContent = "Change anytime in Settings"; + link.textContent = t("disclosure-bar.changeInSettings"); link.addEventListener("click", () => { dismiss(); options.onOpenSettings?.(); @@ -107,7 +108,7 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement } else { const span = document.createElement("span"); span.className = "pi-disclosure-bar__muted"; - span.textContent = "· Change anytime in Settings"; + span.textContent = t("disclosure-bar.changeInSettingsMuted"); hint = span; } actions.appendChild(hint); @@ -115,11 +116,11 @@ export function createDisclosureBar(options: DisclosureBarOptions): HTMLElement customizeBtn.addEventListener("click", () => { const isVisible = picker.classList.toggle("is-visible"); if (isVisible) { - gotItBtn.textContent = "Done"; + gotItBtn.textContent = t("disclosure-bar.done"); customizeBtn.style.display = "none"; hint.style.display = "none"; } else { - gotItBtn.textContent = "Got it"; + gotItBtn.textContent = t("disclosure-bar.gotIt"); customizeBtn.style.display = ""; hint.style.display = ""; } diff --git a/src/ui/files-dialog-actions.ts b/src/ui/files-dialog-actions.ts index a6886a7a..226e09d3 100644 --- a/src/ui/files-dialog-actions.ts +++ b/src/ui/files-dialog-actions.ts @@ -178,7 +178,7 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA const copyButton = document.createElement("button"); copyButton.type = "button"; copyButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"; - copyButton.textContent = "Copy content"; + copyButton.textContent = t("files-dialog-actions.copyContent"); copyButton.addEventListener("click", () => { void (async () => { const result = await options.workspace.readFile(options.file.path, { @@ -197,7 +197,7 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA const downloadButton = document.createElement("button"); downloadButton.type = "button"; downloadButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"; - downloadButton.textContent = "Download"; + downloadButton.textContent = t("files-dialog-actions.download"); downloadButton.addEventListener("click", () => { void (async () => { const result = await options.workspace.readFile(options.file.path, { @@ -225,7 +225,7 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA openButton.className = options.file.kind === "text" ? "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact" : "pi-overlay-btn pi-overlay-btn--primary pi-overlay-btn--compact"; - openButton.textContent = "Open ↗"; + openButton.textContent = t("files-dialog-actions.open"); openButton.addEventListener("click", () => { void openFileInBrowser({ file: options.file, @@ -240,7 +240,7 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA const downloadButton = document.createElement("button"); downloadButton.type = "button"; downloadButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"; - downloadButton.textContent = "Download"; + downloadButton.textContent = t("files-dialog-actions.download"); downloadButton.addEventListener("click", () => { void options.workspace.downloadFile(options.file.path, { locationKind: options.fileRef.locationKind, @@ -260,7 +260,7 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA const renameButton = document.createElement("button"); renameButton.type = "button"; renameButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"; - renameButton.textContent = "Rename"; + renameButton.textContent = t("files-dialog-actions.rename"); renameButton.addEventListener("click", () => { void (async () => { const nextPathInput = await requestTextInputDialog({ @@ -268,8 +268,8 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA message: `${options.file.path} — leave off the extension to keep it.`, initialValue: options.file.path, placeholder: "folder/file.ext", - confirmLabel: "Rename", - cancelLabel: "Cancel", + confirmLabel: t("files-dialog-actions.rename"), + cancelLabel: t("files-dialog-actions.cancel"), restoreFocusOnClose: false, }); @@ -301,14 +301,14 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "pi-overlay-btn pi-overlay-btn--danger pi-overlay-btn--compact"; - deleteButton.textContent = "Delete"; + deleteButton.textContent = t("files-dialog-actions.delete"); deleteButton.addEventListener("click", () => { void (async () => { const confirmed = await requestConfirmationDialog({ title: "Delete file?", message: options.file.path, - confirmLabel: "Delete", - cancelLabel: "Cancel", + confirmLabel: t("files-dialog-actions.delete"), + cancelLabel: t("files-dialog-actions.cancel"), confirmButtonTone: "danger", restoreFocusOnClose: false, }); diff --git a/src/ui/files-dialog-filtering.ts b/src/ui/files-dialog-filtering.ts index f1490a32..6ed5ff83 100644 --- a/src/ui/files-dialog-filtering.ts +++ b/src/ui/files-dialog-filtering.ts @@ -1,3 +1,4 @@ +import { t } from "../language/index.js"; import type { WorkspaceBackendStatus, WorkspaceFileEntry } from "../files/types.js"; export interface FilesDialogBadge { @@ -316,7 +317,7 @@ export function buildFilesDialogSections(args: { if (builtinFiles.length > 0) { sections.push({ key: BUILTIN_DOCS_SECTION_KEY, - label: "BUILT-IN DOCS", + label: t("files-dialog-filtering.sectionBuiltinDocs"), files: [...builtinFiles].sort((a, b) => a.path.localeCompare(b.path)), folders: [], }); @@ -339,7 +340,7 @@ export function resolveFilesDialogConnectFolderButtonState( return { hidden: true, disabled: true, - label: "Connect folder", + label: t("files-dialog-filtering.connectFolder"), title: "", }; } @@ -356,7 +357,7 @@ export function resolveFilesDialogConnectFolderButtonState( return { hidden: false, disabled: false, - label: "Connect folder", + label: t("files-dialog-filtering.connectFolder"), title: "Connect local folder", }; } diff --git a/src/ui/files-dialog-status.ts b/src/ui/files-dialog-status.ts index 93c8bc19..dac4ce96 100644 --- a/src/ui/files-dialog-status.ts +++ b/src/ui/files-dialog-status.ts @@ -1,7 +1,8 @@ +import { t } from "../language/index.js"; import { formatBytes } from "../files/mime.js"; function formatFileCount(totalCount: number): string { - return `${totalCount} file${totalCount === 1 ? "" : "s"}`; + return t("files-dialog.fileCount", { count: String(totalCount) }); } function formatBackendLabel(args: { diff --git a/src/ui/files-dialog.ts b/src/ui/files-dialog.ts index 561f1ecc..03546ce3 100644 --- a/src/ui/files-dialog.ts +++ b/src/ui/files-dialog.ts @@ -37,6 +37,7 @@ import { } from "./overlay-dialog.js"; import { FILES_WORKSPACE_OVERLAY_ID } from "./overlay-ids.js"; import { showToast } from "./toast.js"; +import { t } from "../language/index.js"; import type { IconContent } from "./extensions-hub-components.js"; import { lucide, @@ -97,10 +98,10 @@ function formatRelativeDate(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; - if (diff < 60_000) return "just now"; - if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`; - if (diff < 604_800_000) return `${Math.round(diff / 86_400_000)}d ago`; + if (diff < 60_000) return t("date.justNow"); + if (diff < 3_600_000) return t("date.minutesAgo", { n: Math.round(diff / 60_000) }); + if (diff < 86_400_000) return t("date.hoursAgo", { n: Math.round(diff / 3_600_000) }); + if (diff < 604_800_000) return t("date.daysAgo", { n: Math.round(diff / 86_400_000) }); return new Date(timestamp).toLocaleDateString(); } @@ -345,21 +346,21 @@ function createEmptyState(onUpload: () => void): HTMLDivElement { const title = document.createElement("div"); title.className = "pi-files-empty__title"; - title.textContent = "Give Pi more context"; + title.textContent = t("files-dialog.emptyTitle"); const description = document.createElement("p"); description.className = "pi-files-empty__desc"; - description.textContent = "Upload documents, data, or reference material to help Pi give better answers."; + description.textContent = t("files-dialog.emptyDescription"); const uploadButton = document.createElement("button"); uploadButton.type = "button"; uploadButton.className = "pi-overlay-btn pi-overlay-btn--primary pi-overlay-btn--compact"; - uploadButton.textContent = "Upload files"; + uploadButton.textContent = t("files-dialog.uploadButtonText"); uploadButton.addEventListener("click", onUpload); const hint = document.createElement("p"); hint.className = "pi-files-empty__hint"; - hint.textContent = "Files are stored locally in your browser."; + hint.textContent = t("files-dialog.emptyHint"); empty.append(emptyIcon, title, description, uploadButton, hint); return empty; @@ -397,9 +398,9 @@ export async function showFilesWorkspaceDialog(): Promise { const listHeaderElements = createOverlayHeader({ onClose: closeOverlay, - closeLabel: "Close files", - title: "Files", - subtitle: "Documents available to Pi", + closeLabel: t("files-dialog.closeLabel"), + title: t("files-dialog.title"), + subtitle: t("files-dialog.subtitle"), }); const detailHeader = document.createElement("div"); @@ -412,7 +413,7 @@ export async function showFilesWorkspaceDialog(): Promise { const detailBackButton = document.createElement("button"); detailBackButton.type = "button"; detailBackButton.className = "pi-files-detail__back"; - detailBackButton.setAttribute("aria-label", "Back to file list"); + detailBackButton.setAttribute("aria-label", t("files-dialog.detailBackButton")); detailBackButton.textContent = "←"; const detailTitleWrap = document.createElement("div"); @@ -429,7 +430,7 @@ export async function showFilesWorkspaceDialog(): Promise { const detailCloseButton = createOverlayCloseButton({ onClose: closeOverlay, - label: "Close files", + label: t("files-dialog.closeLabel"), }); detailHeader.append(detailTitleContainer, detailCloseButton); @@ -439,12 +440,12 @@ export async function showFilesWorkspaceDialog(): Promise { const uploadButton = createUploadActionButton({ icon: lucide(Upload), - label: "Upload", + label: t("files-dialog.uploadButton"), }); const connectFolderButton = createUploadActionButton({ icon: lucide(FolderOpen), - label: "Connect folder", + label: t("files-dialog.connectFolderButton"), }); connectFolderButton.hidden = true; connectFolderButton.disabled = true; @@ -471,7 +472,7 @@ export async function showFilesWorkspaceDialog(): Promise { const filterInput = document.createElement("input"); filterInput.type = "text"; filterInput.className = "pi-files-filter__input"; - filterInput.placeholder = "Filter files…"; + filterInput.placeholder = t("files-dialog.placeholder"); filterInput.addEventListener("input", () => { filterText = filterInput.value; renderListView(); @@ -891,11 +892,11 @@ export async function showFilesWorkspaceDialog(): Promise { const title = document.createElement("div"); title.className = "pi-files-empty__title"; - title.textContent = "No matching files"; + title.textContent = t("files-dialog.filterEmptyTitle"); const description = document.createElement("p"); description.className = "pi-files-empty__desc"; - description.textContent = "Try a different filter term."; + description.textContent = t("files-dialog.filterEmptyDescription"); empty.append(title, description); sectionsHost.appendChild(empty); @@ -960,7 +961,7 @@ export async function showFilesWorkspaceDialog(): Promise { footer.textContent = buildFilesDialogStatusMessage({ totalCount: files.length, totalSizeBytes: totalSize, - backendLabel: backendStatus?.label ?? "Storage unavailable", + backendLabel: backendStatus?.label ?? t("files-dialog.storageUnavailable"), nativeDirectoryName: backendStatus?.nativeConnected ? backendStatus.nativeDirectoryName ?? null : null, }); }; @@ -977,14 +978,14 @@ export async function showFilesWorkspaceDialog(): Promise { const file = findFileByRef(detailFileRef); if (!file) { - showToast("That file is no longer available."); + showToast(t("files-dialog.toast.fileNotAvailable")); showListView(); return; } await renderDetailView(detailFileRef); } catch (error: unknown) { - showToast(`Could not refresh files: ${getErrorMessage(error)}`); + showToast(t("files-dialog.toast.refreshFailed", { error: getErrorMessage(error) })); } })(); }; @@ -1006,7 +1007,7 @@ export async function showFilesWorkspaceDialog(): Promise { void workspace.connectNativeDirectory({ audit: DIALOG_AUDIT_CONTEXT, }).catch((error: unknown) => { - showToast(`Connect folder failed: ${getErrorMessage(error)}`); + showToast(t("files-dialog.toast.connectFolderFailed", { error: getErrorMessage(error) })); }); }); @@ -1026,7 +1027,7 @@ export async function showFilesWorkspaceDialog(): Promise { showToast(`Imported ${count} file${count === 1 ? "" : "s"}.`); }) .catch((error: unknown) => { - showToast(`Upload failed: ${getErrorMessage(error)}`); + showToast(t("files-dialog.toast.uploadFailed", { error: getErrorMessage(error) })); }); }); @@ -1043,7 +1044,7 @@ export async function showFilesWorkspaceDialog(): Promise { renderListView(); setView("list"); } catch (error: unknown) { - showToast(`Could not load files: ${getErrorMessage(error)}`); + showToast(t("files-dialog.toast.loadFailed", { error: getErrorMessage(error) })); showListView(); renderListView(); } diff --git a/src/ui/loading.ts b/src/ui/loading.ts index 2b9065e7..a1092921 100644 --- a/src/ui/loading.ts +++ b/src/ui/loading.ts @@ -5,6 +5,7 @@ */ import { html, type TemplateResult } from "lit"; +import { t } from "../language/index.js"; /** * Render the loading spinner. @@ -16,7 +17,7 @@ export function renderLoading(): TemplateResult {
- Initializing… + ${t("loading.initializing")} `; } diff --git a/src/ui/message-renderers.ts b/src/ui/message-renderers.ts index e40d5d37..b824c0c0 100644 --- a/src/ui/message-renderers.ts +++ b/src/ui/message-renderers.ts @@ -9,6 +9,7 @@ import { html } from "lit"; import { createRef, ref } from "lit/directives/ref.js"; import { registerMessageRenderer } from "@earendil-works/pi-web-ui/dist/components/message-renderer-registry.js"; import { renderCollapsibleToolCardHeader } from "./tool-card-header.js"; +import { t } from "../language/index.js"; import type { CompactionSummaryMessage } from "../messages/compaction.js"; import type { ArchivedMessagesMessage } from "../messages/archived-history.js"; @@ -25,7 +26,7 @@ registerMessageRenderer("archivedMessages", { const title = html` - Show earlier messages + ${t("message-renderers.showEarlier")} ${message.archivedChatMessageCount} chat message${message.archivedChatMessageCount === 1 ? "" : "s"} `; @@ -47,9 +48,9 @@ registerMessageRenderer("archivedMessages", {
- + ${message.archivedMessages.length === 0 - ? html`
(no archived messages)
` + ? html`
${t("message-renderers.noArchived")}
` : html` - Summarized ${message.messageCountBefore} messages + ${t("message-renderers.summarized", { count: String(message.messageCountBefore) })} `; @@ -95,9 +96,9 @@ registerMessageRenderer("compactionSummary", {
- +
- +
diff --git a/src/ui/pi-input.ts b/src/ui/pi-input.ts index 05cc0a0a..908953c7 100644 --- a/src/ui/pi-input.ts +++ b/src/ui/pi-input.ts @@ -17,12 +17,13 @@ import { customElement, property, state, query } from "lit/decorators.js"; import { FileText } from "lucide"; import { doesUiClaimStreamingEscape } from "../utils/escape-guard.js"; +import { t } from "../language/index.js"; const PLACEHOLDER_HINTS = [ - "Ask about this workbook…", - "Type / for commands…", - "Ask Pi to edit this workbook…", - "Summarize this workbook…", + t("input.placeholder.ask"), + t("input.placeholder.commands"), + t("input.placeholder.edit"), + t("input.placeholder.summarize"), ]; @customElement("pi-input") @@ -170,34 +171,34 @@ export class PiInput extends LitElement { class="pi-input-btn pi-input-btn--attach" type="button" @click=${this._openFilesWorkspace} - aria-label="Browse files" - title="Browse files" + aria-label=${t("input.attach.aria")} + title=${t("input.attach.aria")} > ${icon(FileText, "sm")} ${this._isDragOver - ? html`
Drop files to import into Files
` + ? html`
${t("input.drop.hint")}
` : null} ${this.isStreaming ? html` - ` : html` @@ -779,11 +780,11 @@ export class PiSidebar extends LitElement { }} @keydown=${(event: KeyboardEvent) => this._onSessionTabKeyDown(tab.runtimeId, event)} title=${tab.title} - aria-label=${`Open tab ${tab.title}`} + aria-label=${t("sidebar.tabs.open", { title: tab.title })} > ${tab.title} ${tab.lockState === "waiting_for_lock" - ? html`lock…` + ? html`${t("sidebar.tabs.lock")}` : nothing} ${tab.isBusy ? html`` @@ -800,9 +801,9 @@ export class PiSidebar extends LitElement { }} ?disabled=${!canCloseThisTab} title=${tab.lockState === "holding_lock" - ? "Wait for workbook changes to finish" - : "Close tab"} - aria-label="Close tab" + ? t("sidebar.tabs.close.wait") + : t("sidebar.tabs.close")} + aria-label=${t("sidebar.tabs.close")} > × @@ -811,14 +812,14 @@ export class PiSidebar extends LitElement { `; })} - + @@ -827,8 +828,8 @@ export class PiSidebar extends LitElement { `; @@ -989,36 +990,36 @@ export class PiSidebar extends LitElement { private _renderUtilitiesMenu() { return html` - @@ -1214,13 +1215,13 @@ export class PiSidebar extends LitElement {

- Understands and acts in Excel. Remembers how you like things.
Builds its own tools. + ${t("sidebar.empty.tagline")}

${this.emptyHints.map((hint) => html` @@ -495,7 +490,7 @@ export function buildProviderRow( const setConnectedState = (connected: boolean): void => { if (statusEl) { - statusEl.textContent = connected ? "✓ connected" : "set up →"; + statusEl.textContent = connected ? t("provider.connected") : t("provider.set_up"); statusEl.classList.toggle("is-connected", connected); } @@ -523,7 +518,7 @@ export function buildProviderRow( if (oauthBtn) { oauthBtn.addEventListener("click", (e) => { e.stopPropagation(); - oauthBtn.textContent = "Opening login…"; + oauthBtn.textContent = t("provider.opening_login"); oauthBtn.style.opacity = "0.7"; void (async () => { errorEl.hidden = true; @@ -545,7 +540,7 @@ export function buildProviderRow( const userRetried = await showProxyGateDialog(); if (!userRetried) { // User cancelled — reset button and bail. - oauthBtn.textContent = `Login with ${label}`; + oauthBtn.textContent = t("provider.login_with", { label }); oauthBtn.style.opacity = "1"; return; } @@ -560,19 +555,19 @@ export function buildProviderRow( }, onPrompt: async (prompt) => { const helperText = id === "anthropic" - ? "After completing login, your browser may show a localhost page that cannot be reached — that's normal. Copy the full URL from the browser address bar and paste it here." + ? t("provider.oauth.helper.anthropic") : id === "openai-codex" - ? "After login, your browser will show a page that says \"can't be reached\" \u2014 that's normal! Copy the full URL from the browser address bar and paste it here." + ? t("provider.oauth.helper.openai") : id === "google-gemini-cli" || id === "google-antigravity" - ? "After sign-in, your browser will show a page that says \"can't be reached\" \u2014 that's normal! Copy the full URL from the browser address bar and paste it here." + ? t("provider.oauth.helper.google") : undefined; const value = await promptForText({ - title: `Login with ${label}`, + title: t("provider.login_with", { label }), message: prompt.message, placeholder: prompt.placeholder || "", helperText, - submitLabel: "Continue", + submitLabel: t("provider.prompt.continue"), }); if (id === "anthropic") { @@ -604,17 +599,13 @@ export function buildProviderRow( if (isLikelyCors) { errorEl.innerHTML = - "Login couldn't connect — this provider needs a helper running on your Mac. " + - "Open Terminal and run: " + - "npx pi-for-excel-proxy, then try again. " + - `Step-by-step guide →`; + `${t("provider.cors_error")} npx pi-for-excel-proxy${t("provider.cors_error.retry")} ${t("provider.proxy_gate.guide")}`; } else { - errorEl.textContent = msg || "Login failed"; + errorEl.textContent = msg || t("provider.login_failed"); } errorEl.hidden = false; } finally { - oauthBtn.textContent = `Login with ${label}`; + oauthBtn.textContent = t("provider.login_with", { label }); oauthBtn.style.opacity = "1"; } })(); @@ -626,7 +617,7 @@ export function buildProviderRow( disconnectBtn.addEventListener("click", (e) => { e.stopPropagation(); void (async () => { - disconnectBtn.textContent = "Disconnecting…"; + disconnectBtn.textContent = t("provider.disconnecting"); disconnectBtn.disabled = true; disconnectBtn.style.opacity = "0.7"; errorEl.hidden = true; @@ -640,10 +631,10 @@ export function buildProviderRow( onDisconnected?.(row, id, label); } catch (err: unknown) { const msg = getErrorMessage(err); - errorEl.textContent = msg ? `Failed to disconnect: ${msg}` : "Failed to disconnect"; + errorEl.textContent = msg ? t("provider.disconnect_failed_msg", { msg }) : t("provider.disconnect_failed"); errorEl.hidden = false; } finally { - disconnectBtn.textContent = `Disconnect ${label}`; + disconnectBtn.textContent = t("provider.disconnect", { label }); disconnectBtn.disabled = false; disconnectBtn.style.opacity = "1"; } @@ -664,7 +655,7 @@ export function buildProviderRow( } const key = normalized.key; - saveBtn.textContent = "Testing…"; + saveBtn.textContent = t("provider.testing"); saveBtn.style.opacity = "0.7"; errorEl.hidden = true; try { @@ -675,10 +666,10 @@ export function buildProviderRow( expandedRef.current = null; } catch (err: unknown) { const msg = getErrorMessage(err); - errorEl.textContent = msg ? `Failed to save key: ${msg}` : "Failed to save key"; + errorEl.textContent = msg ? t("provider.save_failed_msg", { msg }) : t("provider.save_failed"); errorEl.hidden = false; } finally { - saveBtn.textContent = "Save"; + saveBtn.textContent = t("provider.save"); saveBtn.style.opacity = "1"; } })(); }); diff --git a/src/ui/proxy-banner.ts b/src/ui/proxy-banner.ts index 367cb04a..ac91bbd0 100644 --- a/src/ui/proxy-banner.ts +++ b/src/ui/proxy-banner.ts @@ -5,6 +5,7 @@ * unavailable. Expands inline with quick setup guidance. */ +import { t } from "../language/index.js"; import { AlertTriangle, Check, Copy, lucide } from "./lucide-icons.js"; const PROXY_COMMAND = "npx pi-for-excel-proxy"; @@ -43,14 +44,14 @@ export function createProxyBanner(): ProxyBannerHandle { warningIcon.setAttribute("aria-hidden", "true"); const textLabel = document.createElement("span"); - textLabel.textContent = "Proxy not running · some features won't work."; + textLabel.textContent = t("proxy-banner.warning"); text.append(warningIcon, textLabel); const action = document.createElement("button"); action.type = "button"; action.className = "pi-proxy-banner__action"; - action.textContent = "How to fix →"; + action.textContent = t("proxy-banner.action"); topRow.append(text, action); @@ -60,7 +61,7 @@ export function createProxyBanner(): ProxyBannerHandle { const detailsIntro = document.createElement("p"); detailsIntro.className = "pi-proxy-banner__details-text"; - detailsIntro.textContent = "Run this command in a terminal and keep that window open:"; + detailsIntro.textContent = t("proxy-banner.intro"); const codeRow = document.createElement("div"); codeRow.className = "pi-proxy-banner__code"; @@ -76,14 +77,14 @@ export function createProxyBanner(): ProxyBannerHandle { const renderCopyIcon = (): void => { copyButton.replaceChildren(lucide(Copy)); - copyButton.title = "Copy command"; - copyButton.setAttribute("aria-label", "Copy command"); + copyButton.title = t("proxy-banner.copyCommand"); + copyButton.setAttribute("aria-label", t("proxy-banner.copyCommand")); }; const renderCopiedIcon = (): void => { copyButton.replaceChildren(lucide(Check)); - copyButton.title = "Copied"; - copyButton.setAttribute("aria-label", "Copied"); + copyButton.title = t("proxy-banner.copied"); + copyButton.setAttribute("aria-label", t("proxy-banner.copied")); }; renderCopyIcon(); @@ -115,14 +116,14 @@ export function createProxyBanner(): ProxyBannerHandle { const hint = document.createElement("p"); hint.className = "pi-proxy-banner__hint"; - hint.textContent = "Open Terminal · paste · press Enter · type y and Enter if prompted · leave open"; + hint.textContent = t("proxy-banner.hint"); const guideLink = document.createElement("a"); guideLink.className = "pi-proxy-banner__link"; guideLink.href = INSTALL_GUIDE_URL; guideLink.target = "_blank"; guideLink.rel = "noopener noreferrer"; - guideLink.textContent = "No Node.js? See install guide →"; + guideLink.textContent = t("proxy-banner.guideLink"); details.append(detailsIntro, codeRow, hint, guideLink); @@ -130,7 +131,7 @@ export function createProxyBanner(): ProxyBannerHandle { const shouldOpen = details.hidden; details.hidden = !shouldOpen; root.classList.toggle("is-open", shouldOpen); - action.textContent = shouldOpen ? "Hide steps" : "How to fix →"; + action.textContent = shouldOpen ? t("proxy-banner.hideSteps") : t("proxy-banner.action"); }); root.append(topRow, details); @@ -142,7 +143,7 @@ export function createProxyBanner(): ProxyBannerHandle { if (!shouldShow) { details.hidden = true; root.classList.remove("is-open"); - action.textContent = "How to fix →"; + action.textContent = t("proxy-banner.action"); } }; diff --git a/src/ui/render-csv-table.ts b/src/ui/render-csv-table.ts index 55fe60bb..5a47e8d9 100644 --- a/src/ui/render-csv-table.ts +++ b/src/ui/render-csv-table.ts @@ -26,7 +26,7 @@ async function copyToClipboard(csv: string, btn: HTMLButtonElement): Promise { btn.textContent = orig; diff --git a/src/ui/text-input-dialog.ts b/src/ui/text-input-dialog.ts index 6457b19b..efb91376 100644 --- a/src/ui/text-input-dialog.ts +++ b/src/ui/text-input-dialog.ts @@ -6,9 +6,11 @@ import { createOverlayInput, } from "./overlay-dialog.js"; import { TEXT_INPUT_DIALOG_OVERLAY_ID } from "./overlay-ids.js"; +import { t } from "../language/index.js"; -const TEXT_INPUT_UI_UNAVAILABLE_ERROR = - "Text input UI is unavailable in this environment."; +function getTextInputUiUnavailableError(): string { + return t("textInput.unavailable"); +} export interface TextInputDialogOptions { title: string; @@ -32,7 +34,7 @@ function canRenderTextInputDialog(): boolean { export function requestTextInputDialog(options: TextInputDialogOptions): Promise { if (!canRenderTextInputDialog()) { - return Promise.reject(new Error(TEXT_INPUT_UI_UNAVAILABLE_ERROR)); + return Promise.reject(new Error(getTextInputUiUnavailableError())); } const overlayId = options.overlayId ?? TEXT_INPUT_DIALOG_OVERLAY_ID; @@ -63,7 +65,7 @@ export function requestTextInputDialog(options: TextInputDialogOptions): Promise const { header } = createOverlayHeader({ onClose: cancel, - closeLabel: options.cancelLabel ?? "Cancel", + closeLabel: options.cancelLabel ?? t("textInput.cancel"), title: options.title, }); @@ -104,11 +106,11 @@ export function requestTextInputDialog(options: TextInputDialogOptions): Promise actions.className = "pi-overlay-actions"; const cancelButton = createOverlayButton({ - text: options.cancelLabel ?? "Cancel", + text: options.cancelLabel ?? t("textInput.cancel"), }); const confirmButton = createOverlayButton({ - text: options.confirmLabel ?? "Save", + text: options.confirmLabel ?? t("textInput.save"), className: "pi-overlay-btn--primary", }); diff --git a/src/ui/toast.ts b/src/ui/toast.ts index 17ead84c..0b9457c6 100644 --- a/src/ui/toast.ts +++ b/src/ui/toast.ts @@ -2,6 +2,7 @@ * Shared toast helper used across taskpane and commands. */ + interface ToastElements { root: HTMLDivElement; message: HTMLSpanElement; diff --git a/src/ui/tool-renderers.ts b/src/ui/tool-renderers.ts index 2f41444a..c8fcb39e 100644 --- a/src/ui/tool-renderers.ts +++ b/src/ui/tool-renderers.ts @@ -6,24 +6,36 @@ */ import type { ImageContent, TextContent, ToolResultMessage } from "@earendil-works/pi-ai"; +import { t } from "../language/index.js"; import { registerToolRenderer } from "@earendil-works/pi-web-ui/dist/tools/renderer-registry.js"; import type { ToolRenderer, ToolRenderResult } from "@earendil-works/pi-web-ui/dist/tools/types.js"; +import { t } from "../language/index.js"; import { html, type TemplateResult } from "lit"; +import { t } from "../language/index.js"; import { createRef, ref } from "lit/directives/ref.js"; +import { t } from "../language/index.js"; import { renderCollapsibleToolCardHeader, renderToolCardHeader } from "./tool-card-header.js"; +import { t } from "../language/index.js"; import { cellRef, cellRefDisplay, cellRefs } from "./cell-link.js"; +import { t } from "../language/index.js"; import { humanizeToolInput } from "./humanize-params.js"; +import { t } from "../language/index.js"; import { humanizeColorsInText } from "./color-names.js"; +import { t } from "../language/index.js"; import { stripYamlFrontmatter } from "./markdown-preprocess.js"; +import { t } from "../language/index.js"; import { TOOL_NAMES_WITH_RENDERER, type UiToolName } from "../tools/capabilities.js"; +import { t } from "../language/index.js"; import { mountSearchSetupCard, shouldShowSearchSetupCard, } from "./web-search-setup-card.js"; +import { t } from "../language/index.js"; import { mountBridgeSetupCard, shouldShowBridgeSetupCard, } from "./bridge-setup-card.js"; +import { t } from "../language/index.js"; import { isCommentsDetails, isConditionalFormatDetails, @@ -43,12 +55,16 @@ import { type RecoveryCheckpointDetails, type WriteCellsDetails, } from "../tools/tool-details.js"; +import { t } from "../language/index.js"; import { getToolExecutionMode } from "../tools/execution-policy.js"; +import { t } from "../language/index.js"; import { buildChangeExplanation, type ChangeExplanationInput, } from "../audit/change-explanation.js"; +import { t } from "../language/index.js"; import { renderCsvTable } from "./render-csv-table.js"; +import { t } from "../language/index.js"; import { renderDepTree } from "./render-dep-tree.js"; // Ensure custom element is registered before we render it. @@ -734,7 +750,7 @@ function describeToolCall( } case "get_workbook_overview": { const sheet = p.sheet as string | undefined; - return { action: "Overview", detail: sheet ?? "" }; + return { action: t("tools.action.overview"), detail: sheet ?? "" }; } // ── Write tools ── diff --git a/src/ui/web-search-setup-card.ts b/src/ui/web-search-setup-card.ts index 8c2dbeb8..7ebdd196 100644 --- a/src/ui/web-search-setup-card.ts +++ b/src/ui/web-search-setup-card.ts @@ -27,6 +27,7 @@ import { isWebSearchDetails, type WebSearchDetails } from "../tools/tool-details import { validateWebSearchApiKey } from "../tools/web-search.js"; import { AlertTriangle, Check, Copy, Search, lucide } from "./lucide-icons.js"; import { showToast } from "./toast.js"; +import { t } from "../language/index.js"; const PROXY_COMMAND = "npx pi-for-excel-proxy"; @@ -67,7 +68,7 @@ function createCopyableCommand(command: string): HTMLDivElement { const copyBtn = document.createElement("button"); copyBtn.type = "button"; copyBtn.className = "pi-search-setup__copy"; - copyBtn.title = "Copy command"; + copyBtn.title = t("bridge-setup.copyCommandTitle"); copyBtn.setAttribute("aria-label", "Copy command"); copyBtn.replaceChildren(lucide(Copy)); @@ -76,7 +77,7 @@ function createCopyableCommand(command: string): HTMLDivElement { copyBtn.addEventListener("click", () => { copyToClipboard(command, () => { copyBtn.replaceChildren(lucide(Check)); - copyBtn.title = "Copied"; + copyBtn.title = t("bridge-setup.copiedTitle"); copyBtn.setAttribute("aria-label", "Copied"); if (resetTimeout !== null) { @@ -85,7 +86,7 @@ function createCopyableCommand(command: string): HTMLDivElement { resetTimeout = setTimeout(() => { copyBtn.replaceChildren(lucide(Copy)); - copyBtn.title = "Copy command"; + copyBtn.title = t("bridge-setup.copyCommandTitle"); copyBtn.setAttribute("aria-label", "Copy command"); resetTimeout = null; }, 1400); @@ -103,12 +104,12 @@ function createProxyStep(options: ProxyStepOptions): HTMLDivElement { const label = document.createElement("p"); label.className = "pi-search-setup__step-label"; label.textContent = options.stepNumber !== null - ? `Step ${options.stepNumber} · Start the helper (keep it running):` - : "Start the helper (keep it running):"; + ? t("web-search-setup.stepLabel", { n: String(options.stepNumber) }) + : t("web-search-setup.startHelper"); const hint = document.createElement("p"); hint.className = "pi-search-setup__hint"; - hint.textContent = "Open Terminal · paste · press Enter · wait for \"Proxy listening\""; + hint.textContent = t("web-search-setup.helper-instructions"); const actions = document.createElement("div"); actions.className = "pi-search-setup__actions"; @@ -116,7 +117,7 @@ function createProxyStep(options: ProxyStepOptions): HTMLDivElement { const retryBtn = document.createElement("button"); retryBtn.type = "button"; retryBtn.className = "pi-search-setup__retry"; - retryBtn.textContent = "Retry"; + retryBtn.textContent = t("web-search-setup.retry"); const status = document.createElement("span"); status.className = "pi-search-setup__status"; @@ -132,8 +133,8 @@ function createProxyStep(options: ProxyStepOptions): HTMLDivElement { checking = true; retryBtn.disabled = true; - retryBtn.textContent = "Checking…"; - status.textContent = "Checking helper…"; + retryBtn.textContent = t("web-search-setup.checking"); + status.textContent = t("web-search-setup.checking-helper"); status.className = "pi-search-setup__status"; const probeUrl = options.proxyBaseUrl ?? DEFAULT_LOCAL_PROXY_URL; @@ -141,23 +142,23 @@ function createProxyStep(options: ProxyStepOptions): HTMLDivElement { void probeProxyReachability(probeUrl, 1500).then( (reachable) => { if (reachable) { - status.textContent = "✓ Helper detected"; + status.textContent = t("web-search-setup.helperDetected"); status.className = "pi-search-setup__status is-ok"; options.onProxyReady?.(); return; } - status.textContent = "Helper not detected yet — keep terminal open and try again."; + status.textContent = t("web-search-setup.helper-not-detected"); status.className = "pi-search-setup__status is-warn"; }, () => { - status.textContent = "Could not check helper status right now."; + status.textContent = t("web-search-setup.check-failed"); status.className = "pi-search-setup__status is-error"; }, ).finally(() => { checking = false; retryBtn.disabled = false; - retryBtn.textContent = "Retry"; + retryBtn.textContent = t("web-search-setup.retry"); }); }); @@ -203,7 +204,7 @@ function createKeyStep( const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "pi-search-setup__save"; - saveBtn.textContent = "Save"; + saveBtn.textContent = t("web-search-setup.save"); const status = document.createElement("span"); status.className = "pi-search-setup__status"; @@ -219,7 +220,7 @@ function createKeyStep( const key = input.value.trim(); if (key.length === 0) { - showToast("Enter an API key first."); + showToast(t("web-search-setup.toast.enterApiKey")); return; } @@ -232,7 +233,7 @@ function createKeyStep( status.textContent = `⚠️ ${formatWarning} Saving anyway…`; status.className = "pi-search-setup__status is-warn"; } else { - status.textContent = "Saving…"; + status.textContent = t("web-search-setup.saving"); status.className = "pi-search-setup__status"; } @@ -240,7 +241,7 @@ function createKeyStep( try { await saveWebSearchApiKey(settings, provider, key); - status.textContent = "Validating…"; + status.textContent = t("web-search-setup.validating"); status.className = "pi-search-setup__status"; const result = await validateWebSearchApiKey({ provider, apiKey: key, proxyBaseUrl }); @@ -280,7 +281,7 @@ function buildCardContent( const { mode, provider, proxyBaseUrl } = context; const markDone = (): void => { - showToast("✓ Web search is ready — ask the assistant to try again."); + showToast(t("web-search-setup.toast.ready")); onDismiss(); }; @@ -330,7 +331,7 @@ function buildCardContent( case "generic_error": { const message = document.createElement("p"); message.className = "pi-search-setup__text"; - message.textContent = "Check your API key and proxy configuration in /tools."; + message.textContent = t("web-search-setup.check-config"); body.append(message); return { title: "Web search failed", body }; } @@ -361,7 +362,7 @@ export function mountSearchSetupCard(container: HTMLElement, details: WebSearchD const titleEl = document.createElement("span"); titleEl.className = "pi-search-setup__title"; - titleEl.textContent = "Checking search setup…"; + titleEl.textContent = t("web-search-setup.checking-setup"); header.append(icon, titleEl); card.append(header); @@ -399,7 +400,7 @@ export function mountSearchSetupCard(container: HTMLElement, details: WebSearchD const dismissBtn = document.createElement("button"); dismissBtn.type = "button"; dismissBtn.className = "pi-search-setup__dismiss"; - dismissBtn.textContent = "Dismiss"; + dismissBtn.textContent = t("web-search-setup.dismiss"); dismissBtn.addEventListener("click", dismiss); footer.append(dismissBtn); diff --git a/src/ui/whimsical-messages.ts b/src/ui/whimsical-messages.ts index f60b7bf3..8cc10483 100644 --- a/src/ui/whimsical-messages.ts +++ b/src/ui/whimsical-messages.ts @@ -5,153 +5,155 @@ * for a spreadsheet / finance audience instead of a coding agent. */ +import { t } from "../language/index.js"; + const messages: string[] = [ // ── Short — universally charming verbs ────────────────── - "Schlepping…", - "Combobulating…", - "Vibing…", - "Concocting…", - "Transmuting…", - "Pontificating…", - "Cogitating…", - "Noodling…", - "Percolating…", - "Ruminating…", - "Simmering…", - "Marinating…", - "Fermenting…", - "Brewing…", - "Steeping…", - "Contemplating…", - "Musing…", - "Pondering…", - "Mulling…", - "Daydreaming…", - "Tinkering…", - "Finagling…", - "Wrangling…", - "Meandering…", - "Moseying…", - "Pottering…", - "Bumbling…", - "Futzing…", - "Kerfuffling…", - "Bamboozling…", - "Discombobulating…", - "Recombobulating…", - "Confabulating…", - "Flummoxing…", - "Befuddling…", - "Effervescing…", - "Fizzing…", - "Bubbling…", - "Scintillating…", - "Improvising…", - "Frolicking…", + t("whimsical.schlepping"), + t("whimsical.combobulating"), + t("whimsical.vibing"), + t("whimsical.concocting"), + t("whimsical.transmuting"), + t("whimsical.pontificating"), + t("whimsical.cogitating"), + t("whimsical.noodling"), + t("whimsical.percolating"), + t("whimsical.ruminating"), + t("whimsical.simmering"), + t("whimsical.marinating"), + t("whimsical.fermenting"), + t("whimsical.brewing"), + t("whimsical.steeping"), + t("whimsical.contemplating"), + t("whimsical.musing"), + t("whimsical.pondering"), + t("whimsical.mulling"), + t("whimsical.daydreaming"), + t("whimsical.tinkering"), + t("whimsical.finagling"), + t("whimsical.wrangling"), + t("whimsical.meandering"), + t("whimsical.moseying"), + t("whimsical.pottering"), + t("whimsical.bumbling"), + t("whimsical.futzing"), + t("whimsical.kerfuffling"), + t("whimsical.bamboozling"), + t("whimsical.discombobulating"), + t("whimsical.recombobulating"), + t("whimsical.confabulating"), + t("whimsical.flummoxing"), + t("whimsical.befuddling"), + t("whimsical.effervescing"), + t("whimsical.fizzing"), + t("whimsical.bubbling"), + t("whimsical.scintillating"), + t("whimsical.improvising"), + t("whimsical.frolicking"), // ── Short — spreadsheet / finance flavored ────────────── - "Calculating…", - "Recalculating…", - "Pivoting…", - "Subtotaling…", - "Autofilling…", - "Tabulating…", - "Auditing…", - "Reconciling…", - "Amortizing…", - "Compounding…", - "Accruing…", - "Depreciating…", - "Forecasting…", - "Extrapolating…", - "Interpolating…", + t("whimsical.calculating"), + t("whimsical.recalculating"), + t("whimsical.pivoting"), + t("whimsical.subtotaling"), + t("whimsical.autofilling"), + t("whimsical.tabulating"), + t("whimsical.auditing"), + t("whimsical.reconciling"), + t("whimsical.amortizing"), + t("whimsical.compounding"), + t("whimsical.accruing"), + t("whimsical.depreciating"), + t("whimsical.forecasting"), + t("whimsical.extrapolating"), + t("whimsical.interpolating"), // ── Long — universally fun ────────────────────────────── - "Consulting the void…", - "Asking the electrons…", - "Negotiating with entropy…", - "Waxing philosophical…", - "Reading tea leaves…", - "Shaking the magic 8-ball…", - "Warming up the hamsters…", - "Having a little think…", - "Stroking chin thoughtfully…", - "Squinting at the problem…", - "Staring into the abyss…", - "Abyss staring back…", - "Achieving enlightenment…", - "Consulting the oracle…", - "Reticulating splines…", - "Calibrating the flux capacitor…", - "Hoping for the best…", - "Manifesting solutions…", - "Willing it into existence…", - "Believing really hard…", - "Reading the room…", - "Kicking the tires…", - "Dusting off the neurons…", - "Rearranging deck chairs…", + t("whimsical.consulting_the_void"), + t("whimsical.asking_the_electrons"), + t("whimsical.negotiating_with_entropy"), + t("whimsical.waxing_philosophical"), + t("whimsical.reading_tea_leaves"), + t("whimsical.shaking_the_magic_8_ball"), + t("whimsical.warming_up_the_hamsters"), + t("whimsical.having_a_little_think"), + t("whimsical.stroking_chin_thoughtfully"), + t("whimsical.squinting_at_the_problem"), + t("whimsical.staring_into_the_abyss"), + t("whimsical.abyss_staring_back"), + t("whimsical.achieving_enlightenment"), + t("whimsical.consulting_the_oracle"), + t("whimsical.reticulating_splines"), + t("whimsical.calibrating_the_flux_capacitor"), + t("whimsical.hoping_for_the_best"), + t("whimsical.manifesting_solutions"), + t("whimsical.willing_it_into_existence"), + t("whimsical.believing_really_hard"), + t("whimsical.reading_the_room"), + t("whimsical.kicking_the_tires"), + t("whimsical.dusting_off_the_neurons"), + t("whimsical.rearranging_deck_chairs"), // ── Long — spreadsheet & Excel themed ─────────────────── - "Appeasing the circular reference…", - "Bribing the formula bar…", - "Reasoning with rounding errors…", - "Pleading with the print preview…", - "Herding cells into alignment…", - "Wrestling with array formulas…", - "Taming wild #REF! errors…", - "Hunting for the missing penny…", - "Consulting the spreadsheet gods…", - "Reticulating spreadsheets…", - "Massaging the margins…", - "Having words with merged cells…", - "Flirting with conditional formatting…", - "Negotiating with column widths…", - "Asking INDEX MATCH nicely…", - "Befriending the Ribbon…", - "Tiptoeing past the macros…", - "Convincing the cells to cooperate…", - "Feeding the data validation…", - "Warming up the what-if analysis…", - "Cross-referencing the worksheets…", - "Auditing the formula trail…", - "Tracing the precedents…", - "Evaluating the dependents…", - "Freezing the panes thoughtfully…", - "Persuading OFFSET to cooperate…", - "Checking under the hood of INDIRECT…", + t("whimsical.appeasing_the_circular_reference"), + t("whimsical.bribing_the_formula_bar"), + t("whimsical.reasoning_with_rounding_errors"), + t("whimsical.pleading_with_the_print_preview"), + t("whimsical.herding_cells_into_alignment"), + t("whimsical.wrestling_with_array_formulas"), + t("whimsical.taming_wild_ref_errors"), + t("whimsical.hunting_for_the_missing_penny"), + t("whimsical.consulting_the_spreadsheet_gods"), + t("whimsical.reticulating_spreadsheets"), + t("whimsical.massaging_the_margins"), + t("whimsical.having_words_with_merged_cells"), + t("whimsical.flirting_with_conditional_formatting"), + t("whimsical.negotiating_with_column_widths"), + t("whimsical.asking_index_match_nicely"), + t("whimsical.befriending_the_ribbon"), + t("whimsical.tiptoeing_past_the_macros"), + t("whimsical.convincing_the_cells_to_cooperate"), + t("whimsical.feeding_the_data_validation"), + t("whimsical.warming_up_the_what_if_analysis"), + t("whimsical.cross_referencing_the_worksheets"), + t("whimsical.auditing_the_formula_trail"), + t("whimsical.tracing_the_precedents"), + t("whimsical.evaluating_the_dependents"), + t("whimsical.freezing_the_panes_thoughtfully"), + t("whimsical.persuading_offset_to_cooperate"), + t("whimsical.checking_under_the_hood_of_indirect"), // ── Long — finance & modeling themed ──────────────────── - "Balancing the books…", - "Crunching the numbers…", - "Counting beans…", - "Discounting future cash flows…", - "Adjusting for seasonality…", - "Running the Monte Carlo…", - "Stress-testing the model…", - "Sanity-checking the totals…", - "Reconciling to the penny…", - "Marking to market…", - "Rolling forward the forecast…", - "Building the bridge…", - "Waterfalling the revenue…", - "Sensitizing the assumptions…", - "Triangulating the valuation…", - "Normalizing the EBITDA…", - "Checking the foot…", - "Tying out the balance sheet…", - "Hardcoding the overrides…", - "Forgetting the mid-year convention…", + t("whimsical.balancing_the_books"), + t("whimsical.crunching_the_numbers"), + t("whimsical.counting_beans"), + t("whimsical.discounting_future_cash_flows"), + t("whimsical.adjusting_for_seasonality"), + t("whimsical.running_the_monte_carlo"), + t("whimsical.stress_testing_the_model"), + t("whimsical.sanity_checking_the_totals"), + t("whimsical.reconciling_to_the_penny"), + t("whimsical.marking_to_market"), + t("whimsical.rolling_forward_the_forecast"), + t("whimsical.building_the_bridge"), + t("whimsical.waterfalling_the_revenue"), + t("whimsical.sensitizing_the_assumptions"), + t("whimsical.triangulating_the_valuation"), + t("whimsical.normalizing_the_ebitda"), + t("whimsical.checking_the_foot"), + t("whimsical.tying_out_the_balance_sheet"), + t("whimsical.hardcoding_the_overrides"), + t("whimsical.forgetting_the_mid_year_convention"), ]; /** Pick a random message, avoiding the one currently shown. */ export function pickWhimsicalMessage(current?: string): string { - if (messages.length <= 1) return messages[0] ?? "Working…"; + if (messages.length <= 1) return messages[0] ?? t("working.default"); let msg: string; do { msg = messages[Math.floor(Math.random() * messages.length)]; diff --git a/src/ui/working-indicator.ts b/src/ui/working-indicator.ts index 5f7792b1..c2621739 100644 --- a/src/ui/working-indicator.ts +++ b/src/ui/working-indicator.ts @@ -11,13 +11,14 @@ import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { pickWhimsicalMessage } from "./whimsical-messages.js"; +import { t } from "../language/index.js"; const HINTS: string[] = [ - "press Esc to stop", - "⇧Tab to adjust reasoning depth", - "type / to see commands", - "⌃O to collapse tool details", - "press Enter to redirect Pi", + t("working.hint.escape"), + t("working.hint.reasoning"), + t("working.hint.commands"), + t("working.hint.collapse"), + t("working.hint.redirect"), ]; @customElement("pi-working-indicator") @@ -30,7 +31,7 @@ export class WorkingIndicator extends LitElement { /** Optional fixed hint (disables hint rotation). */ @property({ type: String }) hintText?: string; - @state() private _whimsical = "Working…"; + @state() private _whimsical = t("working.default"); @state() private _hintIndex = 0; @state() private _fadingWhimsical = false; @state() private _fadingHint = false; @@ -66,7 +67,7 @@ export class WorkingIndicator extends LitElement { this._stopRotation(); this._fadingWhimsical = false; this._fadingHint = false; - this._whimsical = this.primaryText || "Working…"; + this._whimsical = this.primaryText || t("working.default"); this._hintIndex = 0; return; } @@ -75,7 +76,7 @@ export class WorkingIndicator extends LitElement { if (this._hintTimer) return; this._stopRotation(); // Reset to initial state — random hint from the start - this._whimsical = "Working…"; + this._whimsical = t("working.default"); this._hintIndex = Math.floor(Math.random() * HINTS.length); this._fadingWhimsical = false; this._fadingHint = false; diff --git a/src/workbook/context.ts b/src/workbook/context.ts index a6d4d2a1..51c45ab6 100644 --- a/src/workbook/context.ts +++ b/src/workbook/context.ts @@ -7,6 +7,8 @@ * - forward-compatible with future manual link/unlink (where workbookId may come from the workbook itself) */ +import { t } from "../language/index.js"; + export interface WorkbookContext { /** * A stable, local-only identifier when available. @@ -103,7 +105,7 @@ export function formatWorkbookLabel(context: WorkbookContext): string { return `Workbook (${shortId}…)`; } - return "Current workbook"; + return t("workbook.currentWorkbook"); } function fnv1a32Hex(bytes: Uint8Array): string {