From a2614d8998d5351eb9f8cfdc17877feb44afcba0 Mon Sep 17 00:00:00 2001 From: Xianbei233 <16370242+Xianbei233@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:14 +0800 Subject: [PATCH 1/3] feat(i18n): support data-only UI locale extensions --- docs/extensions.md | 47 +++++ docs/index.md | 2 +- src/server/app.ts | 3 + src/server/host.ts | 21 +- src/server/reload.ts | 419 +++++++++++++++++++++++++++++++++++++-- test/dropins.test.ts | 111 ++++++++++- test/i18n-ui.test.ts | 297 +++++++++++++++++++++++++++ test/i18n.test.ts | 65 ++++++ test/ui-locales.test.ts | 390 ++++++++++++++++++++++++++++++++++++ web/app.js | 53 ++--- web/browser-panel.js | 79 ++++---- web/browser.js | 7 +- web/calls.js | 21 +- web/chat.js | 110 ++++++---- web/commands.js | 26 ++- web/composer.js | 79 ++++---- web/diff.js | 95 ++++----- web/files-panel.js | 55 ++--- web/i18n.d.ts | 7 + web/i18n.js | 55 +++++ web/launcher.js | 99 ++++----- web/notify.js | 50 +++-- web/peeks.js | 29 +-- web/rich.js | 15 +- web/sessions.js | 118 +++++------ web/share.js | 11 +- web/sheets.js | 33 +-- web/sheets/appearance.js | 57 +++--- web/sheets/branch.js | 56 +++--- web/sheets/extensions.js | 62 +++--- web/sheets/file.js | 39 ++-- web/sheets/find.js | 19 +- web/sheets/folder.js | 25 +-- web/sheets/menu.js | 140 ++++++++----- web/sheets/message.js | 31 +-- web/sheets/model.js | 24 ++- web/sheets/people.js | 107 +++++----- web/sheets/providers.js | 31 +-- web/sheets/running.js | 15 +- web/sheets/schedules.js | 23 ++- web/sheets/shortcuts.js | 7 +- web/sheets/spend.js | 22 +- web/sheets/text.js | 3 +- web/sheets/viewers.js | 21 +- web/sheets/worktree.js | 24 ++- web/store.js | 37 +++- web/transcript.js | 112 ++++++----- web/ui.js | 33 ++- 48 files changed, 2390 insertions(+), 795 deletions(-) create mode 100644 test/i18n-ui.test.ts create mode 100644 test/i18n.test.ts create mode 100644 test/ui-locales.test.ts create mode 100644 web/i18n.d.ts create mode 100644 web/i18n.js diff --git a/docs/extensions.md b/docs/extensions.md index bb19b062..3c89a2aa 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -56,6 +56,53 @@ The file: - has a file name no built-in module has (a drop-in named `browser.ts` is not loaded). Files that start with `_` or end in `.test.ts` are not modules: use them for helpers and tests. A helper is not reloaded when it changes, even when the module that imports it is: a change to it takes a restart. Keep what you will change often in the module itself. - starts with a `/** … */` comment whose first sentence says what it does. +### UI locale packs (data only) + +A Pocket extension may also return optional `uiLocales` data alongside its ordinary tools, sections, hooks, or tasks. The shape is: + +```typescript +type PocketUiLocale = { + locale: string; // a canonical BCP 47 language tag, such as "zh-CN" + label: string; + default?: boolean; + strings: Record; + templates?: Record; +}; +type PocketExtension = Extension & { uiLocales?: PocketUiLocale[] }; +``` + +Use exact English source strings as keys. `strings` handles a key with no parameters; `templates` handles a source template with named placeholders: + +```typescript +import { defineExtension } from "@earendil-works/pi-durable"; + +export default function createUiLocale() { + return { + ...defineExtension({ name: "example-ui-locale" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Files: "文件" }, + templates: { + "Back to sessions, {{count}} waiting for you": + "返回会话列表,{{count}} 项待你处理", + }, + }, + ], + }; +} +``` + +The web app translates only where its code explicitly calls `t("Files")` or `t("Back to sessions, {{count}} waiting for you", { count })`. Missing keys stay in English. A source template and its translation must contain the same set of placeholder names; for example, both templates above contain `{{count}}`. Interpolated values are inserted as-is; they are not looked up or translated again. This is not a DOM-wide translation pass: user messages, names, paths, model names, logs, and other dynamic content are not translated automatically. + +The loader accepts locale packs only as validated data; their strings and templates are sent to the browser, not extension JavaScript. The same locale cannot be registered by multiple loaded extensions; a conflicting or invalid module is rejected. A drop-in still starts off. Its locale data follows the extension lifecycle: turning it off or removing it removes the pack, editing an enabled module reloads it, and a failed reload leaves the previous extension and pack running. + +Only signed-in clients receive the available packs, as `hello.server.uiLocales` (also through the authenticated `/api/me` response). There is no public dictionary endpoint. An unauthenticated page, or a signed-in client with no enabled locale packs, uses English. The browser prefers a pack marked `default: true`, then sorts by the locale string; `default` is only a preference among packs already enabled, not a switch that enables an extension. There is no per-user language choice or saved locale. + +The loader's canonical-tag and data validation rules may evolve; write canonical BCP 47 tags and do not rely on undocumented size or entry limits. + ### Names Installing an extension with a name already installed replaces it, and a tool with the name of another replaces it in every session. So Pi Pocket refuses a drop-in that would take a name it keeps for itself, or one another module already has, and says so in Menu → Extensions: diff --git a/docs/index.md b/docs/index.md index 75b02527..eef9cc3d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ A drop-in extension survives updates to Pi Pocket and can be turned off from the ## The documents - [customizing.md](customizing.md): changing how Pi works without code. -- [extensions.md](extensions.md): writing extensions (tools, prompt sections, hooks), with working examples in [examples/](examples/). +- [extensions.md](extensions.md): writing extensions (tools, prompt sections, hooks, and UI locale packs), with working examples in [examples/](examples/). - [self-editing.md](self-editing.md): changing Pi Pocket's code while it runs, checking the change, and getting a broken one back. - [map.md](map.md): where each part of the code is, and how to add the common things. - [architecture.md](architecture.md): how the parts depend on each other, and the orderings they rely on. diff --git a/src/server/app.ts b/src/server/app.ts index 6acbe479..0c5036b4 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -437,6 +437,7 @@ export class PocketApp { host, { builtIn: join(APP_ROOT, "src", "server", "extensions"), dropIn }, (file) => this.config.extensionChoice(file), + () => this.#refreshClients(), ); await this.loader.loadAll(); @@ -1521,6 +1522,8 @@ export class PocketApp { home: homedir(), defaultCwd: this.defaultCwd, extensions: this.loader.extensionNames(), + // Data-only UI translation packs from currently enabled extension modules. + uiLocales: this.loader.uiLocales(), // Tells the web app this server has people chat and typing indicators. chat: true, // Tells the web app this server sends peek tiles (`POST /api/peeks`, `peek` events). diff --git a/src/server/host.ts b/src/server/host.ts index d77194ab..60d28e92 100644 --- a/src/server/host.ts +++ b/src/server/host.ts @@ -29,6 +29,25 @@ export interface ApprovalRequest { export type ApprovalAnswer = { allow: boolean; by: string }; +/** A data-only translation pack for explicit UI strings and safe placeholder templates. */ +export interface PocketUiLocale { + /** Canonical BCP 47 language tag, such as `zh-CN`. */ + readonly locale: string; + /** Human-readable name shown by the host when listing available languages. */ + readonly label: string; + /** Prefer this pack when more than one locale is enabled; ties go to the smallest locale tag. */ + readonly default?: boolean; + /** Exact source UI strings mapped to their translations. */ + readonly strings: Readonly>; + /** Exact source templates with `{{name}}` placeholders mapped to translated templates. */ + readonly templates?: Readonly>; +} + +/** A Pi Durable extension with optional data-only user-interface translations. */ +export type PocketExtension = Extension & { + readonly uiLocales?: readonly PocketUiLocale[]; +}; + /** * Tool calls waiting for a human. In memory on purpose: the guard hook runs before a call's intent is stored, so after * a restart the hook runs again and asks again, unless its memo already holds the answer. @@ -193,5 +212,5 @@ export interface PocketHost { /** The shape of every module in `src/server/extensions/`: a default export building one or more extensions. */ export type ExtensionModule = { - default: (host: PocketHost) => Extension | readonly Extension[]; + default: (host: PocketHost) => PocketExtension | readonly PocketExtension[]; }; diff --git a/src/server/reload.ts b/src/server/reload.ts index a225724c..653364df 100644 --- a/src/server/reload.ts +++ b/src/server/reload.ts @@ -21,9 +21,9 @@ import { } from "node:fs"; import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; -import type { Extension, Registry } from "@earendil-works/pi-durable"; +import { createRegistry, type Registry } from "@earendil-works/pi-durable"; import { describe } from "./errors.ts"; -import type { ExtensionModule, PocketHost } from "./host.ts"; +import type { ExtensionModule, PocketExtension, PocketHost, PocketUiLocale } from "./host.ts"; /** Built-in extension modules, in install order. Other `.ts` files in the directory load after them, by name. */ const ORDER = [ @@ -153,16 +153,266 @@ export function prepareDropInFolder(directory: string, appModules: string): void symlinkSync(appModules, link, process.platform === "win32" ? "junction" : "dir"); } +const MAX_UI_LOCALES = 16; +const MAX_UI_ENTRIES = 5000; +const MAX_UI_BYTES = 512 * 1024; +const UI_LOCALE_FIELDS = new Set(["locale", "label", "default", "strings", "templates"]); + +function plainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +} + +/** Read enumerable own data properties without invoking accessors. */ +function dataProperties( + value: Record, + file: string, + field: string, + allowed?: ReadonlySet, +): Map { + const descriptors = Object.getOwnPropertyDescriptors(value); + const result = new Map(); + + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string" || (allowed !== undefined && !allowed.has(key))) { + throw new Error(`${file} UI locale ${field} has an unknown field`); + } + + const descriptor = descriptors[key]!; + + if (!descriptor.enumerable || !("value" in descriptor)) { + throw new Error( + `${file} UI locale ${field} must contain only enumerable data properties`, + ); + } + + result.set(key, descriptor.value); + } + + return result; +} + +function checkedArray(value: unknown, file: string, field: string, limit: number): unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + throw new Error(`${file} UI locale ${field} must be a plain array`); + } + + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + const lengthDescriptor = descriptors.length; + const length = lengthDescriptor?.value; + + if ( + lengthDescriptor === undefined || + !("value" in lengthDescriptor) || + !Number.isSafeInteger(length) || + length < 0 || + length > limit + ) { + throw new Error(`${file} UI locale ${field} has too many entries`); + } + + if (Reflect.ownKeys(descriptors).length !== length + 1) { + throw new Error(`${file} UI locale ${field} must be a dense array without extra fields`); + } + + const result: unknown[] = []; + + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new Error(`${file} UI locale ${field} must be a dense array of data values`); + } + + result.push(descriptor.value); + } + + return result; +} + +function checkedStrings( + value: unknown, + file: string, + field: string, +): Readonly> { + if (!plainRecord(value)) { + throw new Error(`${file} UI locale ${field} must be a plain object of strings`); + } + + const entries = dataProperties(value, file, field); + + if (entries.size > MAX_UI_ENTRIES) { + throw new Error(`${file} UI locale ${field} has too many entries`); + } + + const result: Record = Object.create(null); + let bytes = 0; + + for (const [key, text] of entries) { + if (typeof text !== "string") { + throw new Error(`${file} UI locale ${field} values must be plain strings`); + } + + if (key.length === 0 || key.length > 1000 || text.length > 4000) { + throw new Error(`${file} UI locale ${field} contains an entry that is too long`); + } + + bytes += Buffer.byteLength(key) + Buffer.byteLength(text); + + if (bytes > MAX_UI_BYTES) { + throw new Error(`${file} UI locale ${field} is too large`); + } + + result[key] = text; + } + + return Object.freeze(result); +} + +function normalizeUiLocales( + file: string, + extensions: readonly PocketExtension[], +): PocketExtension[] { + const seen = new Set(); + let count = 0; + + return extensions.map((extension) => { + const descriptor = Object.getOwnPropertyDescriptor(extension, "uiLocales"); + + if (descriptor === undefined) { + if ("uiLocales" in extension) { + throw new Error(`${file} uiLocales must be an own data property`); + } + + return extension; + } + + if (!descriptor.enumerable || !("value" in descriptor)) { + throw new Error(`${file} uiLocales must be an array of data-only locale packs`); + } + + const rawLocales = checkedArray(descriptor.value, file, "uiLocales", MAX_UI_LOCALES); + const locales = rawLocales.map((raw: unknown) => { + if (!plainRecord(raw)) { + throw new Error(`${file} UI locale entries must be plain objects`); + } + + const fields = dataProperties(raw, file, "entry", UI_LOCALE_FIELDS); + const locale = fields.get("locale"); + const label = fields.get("label"); + const preferred = fields.get("default"); + + if (typeof locale !== "string" || locale.length > 35) { + throw new Error(`${file} UI locale needs a valid BCP 47 language tag`); + } + + let canonical: string; + + try { + canonical = Intl.getCanonicalLocales(locale)[0] ?? ""; + } catch { + canonical = ""; + } + + if (canonical === "" || canonical !== locale) { + throw new Error( + `${file} UI locale ${locale} must be a canonical BCP 47 language tag`, + ); + } + + if (seen.has(locale)) { + throw new Error(`${file} registers the ${locale} UI locale more than once`); + } + + seen.add(locale); + count += 1; + + if (count > MAX_UI_LOCALES) { + throw new Error(`${file} registers too many UI locales`); + } + + if (typeof label !== "string" || label.trim() === "" || label.length > 80) { + throw new Error(`${file} UI locale ${locale} needs a short label`); + } + + if (fields.has("default") && typeof preferred !== "boolean") { + throw new Error(`${file} UI locale ${locale} default must be true or false`); + } + + const strings = checkedStrings(fields.get("strings"), file, `${locale} strings`); + const templates = fields.has("templates") + ? checkedStrings(fields.get("templates"), file, `${locale} templates`) + : undefined; + + for (const [source, translated] of Object.entries(templates ?? {})) { + const placeholders = (text: string) => + [ + ...new Set( + [...text.matchAll(/\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g)].map( + (match) => match[1], + ), + ), + ].sort(); + + if ( + JSON.stringify(placeholders(source)) !== + JSON.stringify(placeholders(translated)) + ) { + throw new Error( + `${file} UI locale ${locale} template placeholders must match the source`, + ); + } + } + + const serializedBytes = Buffer.byteLength( + JSON.stringify({ + locale, + label, + ...(fields.has("default") ? { default: preferred } : {}), + strings, + ...(templates === undefined ? {} : { templates }), + }), + ); + + if (serializedBytes > MAX_UI_BYTES) { + throw new Error(`${file} UI locale ${locale} is too large`); + } + + return Object.freeze({ + locale, + label, + ...(fields.has("default") ? { default: preferred as boolean } : {}), + strings, + ...(templates === undefined ? {} : { templates }), + }); + }); + + const normalized = { ...extension, uiLocales: Object.freeze(locales) }; + + Object.defineProperty(normalized, "uiLocales", { writable: false, configurable: false }); + + return normalized; + }); +} + export class ExtensionLoader { readonly #registry: Registry; readonly #host: PocketHost; readonly #builtIn: string; readonly #dropIn: string | undefined; readonly #choice: (file: string) => boolean | undefined; + readonly #onChange: () => Promise; /** Extension names each file installed, to uninstall the ones a new version no longer provides. */ - readonly #installed = new Map(); + readonly #installed = new Map(); readonly #errors = new Map(); readonly #timers = new Map(); + /** Each load owns a version so disabling, deletion, or a newer reload makes it stale. */ + readonly #loadVersions = new Map(); /** Drop-ins already reported for having a built-in module's name, so each is reported once. */ readonly #clashes = new Set(); readonly #watchers: FSWatcher[] = []; @@ -177,12 +427,14 @@ export class ExtensionLoader { host: PocketHost, folders: { builtIn: string; dropIn?: string }, choice: (file: string) => boolean | undefined = () => undefined, + onChange: () => Promise = async () => {}, ) { this.#registry = registry; this.#host = host; this.#builtIn = folders.builtIn; this.#dropIn = folders.dropIn; this.#choice = choice; + this.#onChange = onChange; } /** Every module, built-in ones first in install order. A drop-in with a built-in's name is left out. */ @@ -258,14 +510,18 @@ export class ExtensionLoader { continue; } + const version = this.#beginLoad(module.file); + try { - await this.#load(module.file, false); + await this.#load(module.file, false, version); } catch (error) { - this.#errors.set(module.file, describe(error)); - this.#host.notice( - "error", - `Extension ${module.file} failed to load: ${describe(error)}`, - ); + if (this.#loadVersions.get(module.file) === version) { + this.#errors.set(module.file, describe(error)); + this.#host.notice( + "error", + `Extension ${module.file} failed to load: ${describe(error)}`, + ); + } } } } @@ -288,7 +544,24 @@ export class ExtensionLoader { this.#uninstall(file); } + #beginLoad(file: string): number { + const timer = this.#timers.get(file); + + if (timer !== undefined) { + clearTimeout(timer); + this.#timers.delete(file); + } + + const version = (this.#loadVersions.get(file) ?? 0) + 1; + + this.#loadVersions.set(file, version); + + return version; + } + #uninstall(file: string): void { + this.#beginLoad(file); + for (const extension of this.#installed.get(file) ?? []) { this.#registry.uninstall(extension); } @@ -298,11 +571,15 @@ export class ExtensionLoader { } /** Import a module again and install what it builds. */ - async reload(file: string): Promise { + async reload(file: string): Promise { + const version = this.#beginLoad(file); + try { - return await this.#load(file, true); + return await this.#load(file, true, version); } catch (error) { - this.#errors.set(file, describe(error)); + if (this.#loadVersions.get(file) === version) { + this.#errors.set(file, describe(error)); + } throw error; } @@ -334,7 +611,18 @@ export class ExtensionLoader { return [...this.#installed.values()].flat().map((extension) => extension.name); } - async #load(file: string, cacheBust: boolean): Promise { + /** The data-only locale packs registered by extensions that are on, in deterministic priority order. */ + uiLocales(): PocketUiLocale[] { + return [...this.#installed.values()] + .flatMap((extensions) => extensions.flatMap((extension) => extension.uiLocales ?? [])) + .sort( + (a, b) => + Number(b.default === true) - Number(a.default === true) || + (a.locale < b.locale ? -1 : a.locale > b.locale ? 1 : 0), + ); + } + + async #load(file: string, cacheBust: boolean, version: number): Promise { const module = this.#module(file); if (module === undefined) { @@ -343,22 +631,55 @@ export class ExtensionLoader { const url = pathToFileURL(join(module.directory, file)).href + - (cacheBust ? `?v=${Date.now()}` : ""); + (cacheBust ? `?v=${Date.now()}-${version}` : ""); const loaded = (await import(url)) as ExtensionModule; + const current = this.#module(file); + + if ( + this.#loadVersions.get(file) !== version || + !this.enabled(file) || + current?.directory !== module.directory || + current.source !== module.source + ) { + return this.#installed.get(file) ?? []; + } if (typeof loaded.default !== "function") { throw new Error("the module has no default export function"); } const built = loaded.default(this.#host); - const extensions = (Array.isArray(built) ? built : [built]) as Extension[]; + const extensions = normalizeUiLocales( + file, + (Array.isArray(built) ? built : [built]) as PocketExtension[], + ); if (module.source === "drop-in") { this.#checkNames(file, extensions); } + this.#checkUiLocaleNames(file, extensions); + const previous = this.#installed.get(file) ?? []; + // Validate the complete replacement before publishing any part of it. Registry.install() publishes + // synchronously and a later extension can fail (for example, on a task-name collision). + const staged = createRegistry(); + + for (const extension of this.#registry.snapshot().installed()) { + staged.install(extension); + } + + for (const extension of extensions) { + staged.install(extension); + } + + for (const old of previous) { + if (!extensions.some((extension) => extension.name === old.name)) { + staged.uninstall(old); + } + } + for (const extension of extensions) { this.#registry.install(extension); } @@ -379,7 +700,7 @@ export class ExtensionLoader { * Refuse a drop-in that would take a name Pi Pocket keeps, or one another module installed: installing it would * replace that one. Throws before anything is installed, so a version that loaded before keeps running. */ - #checkNames(file: string, extensions: readonly Extension[]): void { + #checkNames(file: string, extensions: readonly PocketExtension[]): void { const owners = new Map(); const tools = new Map(); @@ -426,6 +747,55 @@ export class ExtensionLoader { } } + #checkUiLocaleNames(file: string, extensions: readonly PocketExtension[]): void { + const owners = new Map(); + let count = 0; + + for (const [other, installed] of this.#installed) { + if (other === file) { + continue; + } + + for (const extension of installed) { + for (const locale of extension.uiLocales ?? []) { + owners.set(locale.locale, other); + count += 1; + } + } + } + + for (const extension of extensions) { + for (const locale of extension.uiLocales ?? []) { + const other = owners.get(locale.locale); + + if (other !== undefined) { + throw new Error( + `${other} already registers the ${locale.locale} UI locale: choose another locale`, + ); + } + + count += 1; + + if (count > MAX_UI_LOCALES) { + throw new Error( + `${file} would register too many UI locales (maximum ${MAX_UI_LOCALES})`, + ); + } + + owners.set(locale.locale, file); + } + } + } + + #notifyClients(): void { + void this.#onChange().catch((error: unknown) => + this.#host.notice( + "warning", + `Could not refresh clients after an extension change: ${describe(error)}`, + ), + ); + } + /** Watch both folders and reload a module shortly after it changes. Keeps the old code when the new one fails. */ watch(): void { for (const directory of [this.#builtIn, this.#dropIn]) { @@ -464,6 +834,7 @@ export class ExtensionLoader { // A drop-in that was removed takes away what it installed. if (module === undefined && directory === this.#dropIn && this.#installed.has(file)) { this.#uninstall(file); + this.#notifyClients(); this.#host.notice("info", `Removed the drop-in extension ${file}.`); return; @@ -487,18 +858,22 @@ export class ExtensionLoader { const loaded = this.#installed.has(file); this.reload(file).then( - (extensions) => + (extensions) => { + this.#notifyClients(); this.#host.notice( "info", `Reloaded ${file}: ${extensions.map((extension) => extension.name).join(", ")}`, - ), - (error: unknown) => + ); + }, + (error: unknown) => { + this.#notifyClients(); this.#host.notice( "error", loaded ? `Kept the previous ${file}; the edited one failed to load: ${describe(error)}` : `${file} failed to load: ${describe(error)}`, - ), + ); + }, ); }, 300), ); @@ -509,8 +884,8 @@ export class ExtensionLoader { watcher.close(); } - for (const timer of this.#timers.values()) { - clearTimeout(timer); + for (const file of new Set([...this.#timers.keys(), ...this.#loadVersions.keys()])) { + this.#beginLoad(file); } } } diff --git a/test/dropins.test.ts b/test/dropins.test.ts index ed8bccdf..7fb58d89 100644 --- a/test/dropins.test.ts +++ b/test/dropins.test.ts @@ -65,11 +65,30 @@ const route: FauxResponseStep = (request) => { const model = scriptedModel(route); +/** A drop-in that registers a data-only UI translation pack. */ +const uiLocale = (translation: string) => `/** Registers a test UI locale. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +export default () => ({ + ...defineExtension({ name: "test-ui-locale" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Extensions: ${JSON.stringify(translation)} }, + templates: { "Back, {{count}} waiting": "返回,{{count}} 项待处理" }, + }, + ], +}); +`; + let app: App; before(async () => { mkdirSync(dropIns, { recursive: true }); writeFileSync(join(dropIns, "shout.ts"), shout("!")); + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("扩展")); // The name of a built-in module: never loaded in its place. writeFileSync( join(dropIns, "plan.ts"), @@ -92,7 +111,7 @@ test("drop-ins are listed after the built-in modules, off until the owner turns assert.equal(listed?.path, join(dropIns, "shout.ts")); assert.equal(listed?.summary, "Shout offers a tool that shouts!."); assert.equal(listed?.enabled, false); - assert.equal(app.loader.list().at(-1)?.file, "shout.ts"); + assert.equal(app.loader.list().at(-1)?.file, "ui-locale.ts"); assert.deepEqual( module("plan.ts").map((each) => each.source), ["built-in"], @@ -155,6 +174,82 @@ test("a drop-in that is on gives Pi its tools and hooks, reloads when edited, an } }); +test("enabled drop-ins publish data-only UI locales and edits or removal update every client's hello", async () => { + await app.setExtensionEnabled(owner(app), "ui-locale.ts", true); + + const hello = await app.hello(owner(app)); + + assert.deepEqual( + hello.server.uiLocales.map(({ locale, label }) => ({ locale, label })), + [{ locale: "zh-CN", label: "简体中文" }], + ); + assert.equal(hello.server.uiLocales[0]?.strings.Extensions, "扩展"); + + writeFileSync( + join(dropIns, "locale-conflict.ts"), + uiLocale("冲突").replace('name: "test-ui-locale"', 'name: "conflicting-locale"'), + ); + await assert.rejects( + app.setExtensionEnabled(owner(app), "locale-conflict.ts", true), + /already registers the zh-CN UI locale/, + ); + await app.setExtensionEnabled(owner(app), "locale-conflict.ts", false); + + const tab = fakeTab(undefined, owner(app)); + + await app.attach(tab.client); + + try { + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("界面扩展")); + await until( + () => + tab.events.some( + (event) => + event.event === "hello" && + (event.data.server as { uiLocales: { strings: Record }[] }) + .uiLocales[0]?.strings.Extensions === "界面扩展", + ), + "edited locale data to reach connected clients", + ); + + rmSync(join(dropIns, "ui-locale.ts")); + await until( + () => + tab.events.some( + (event) => + event.event === "notice" && + String(event.data.message).includes( + "Removed the drop-in extension ui-locale.ts.", + ), + ), + "the locale pack to be uninstalled", + ); + assert.deepEqual(app.loader.uiLocales(), []); + const latestHello = [...tab.events] + .reverse() + .find((event) => event.event === "hello")?.data; + + assert.equal( + (latestHello?.server as { uiLocales: unknown[] }).uiLocales.length, + 0, + "removing the module tells connected clients to use English", + ); + + writeFileSync( + join(dropIns, "invalid-locale.ts"), + `import { defineExtension } from "@earendil-works/pi-durable";\nexport default () => ({ ...defineExtension({ name: "invalid-locale" }), uiLocales: [{ locale: "zh-CN", label: "bad", strings: { Extensions: 7 } }] });`, + ); + await assert.rejects( + app.setExtensionEnabled(owner(app), "invalid-locale.ts", true), + /values must be plain strings/, + ); + assert.deepEqual(app.loader.uiLocales(), [], "invalid data never reaches clients"); + await app.setExtensionEnabled(owner(app), "invalid-locale.ts", false); + } finally { + app.detach(tab.client); + } +}); + /** A drop-in with one extension, and one tool in it when `tool` is given. */ const named = (extension: string, tool?: string) => `/** ${extension}, for the names test. */ import { Type } from "@earendil-works/pi-ai"; @@ -228,3 +323,17 @@ test("the built-in tool names a drop-in may not take are every tool the built-in assert.ok(installed.length >= 9, "the built-ins are on"); assert.deepEqual([...installed].sort(), [...BUILT_IN_TOOLS].sort()); }); + +test("locale enable and disable choices survive app restarts", async () => { + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("扩展")); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", false); + assert.deepEqual(app.loader.uiLocales(), []); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", true); + await app.close(); + app = await openApp(model, dataDir); + assert.equal((await app.hello(owner(app))).server.uiLocales[0]?.strings.Extensions, "扩展"); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", false); + await app.close(); + app = await openApp(model, dataDir); + assert.deepEqual((await app.hello(owner(app))).server.uiLocales, []); +}); diff --git a/test/i18n-ui.test.ts b/test/i18n-ui.test.ts new file mode 100644 index 00000000..08ebc172 --- /dev/null +++ b/test/i18n-ui.test.ts @@ -0,0 +1,297 @@ +// A live locale change reaches already-open guest sessions without a reload, on phone and desktop. +import { + type App, + cleanUp, + newSession, + openApp, + owner, + root, + scriptedModel, + until, +} from "./helpers.ts"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import type { ConversationId } from "@earendil-works/pi-durable"; +import { Browsers } from "../src/server/browser.ts"; +import { findBrowser } from "../src/server/browser/discovery.ts"; +import type { BrowserPage } from "../src/server/browser/page.ts"; +import { VIEWPORTS } from "../src/server/browser/viewport.ts"; +import { createHandler } from "../src/server/http.ts"; + +const chromium = findBrowser(); +const real = { + skip: chromium === undefined ? "no Chromium-based browser on this machine" : false, +} as const; +const dataDir = join(root, "i18n-ui-data"); +const localeFile = "i18n-ui-fixture.ts"; +const draft = "Draft stays here while the interface changes."; + +const localeModule = `/** A small test locale for the live UI translation test. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +export default () => ({ + ...defineExtension({ name: "i18n-ui-fixture" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { + Menu: "菜单", + Extensions: "扩展", + Appearance: "外观", + }, + templates: {}, + }, + ], +}); +`; + +let app: App | undefined; +let server: Server | undefined; +let browsers: Browsers | undefined; +let mobile: BrowserPage | undefined; +let desktop: BrowserPage | undefined; +let base = ""; +let session: ConversationId; +let guestToken = ""; + +before(async () => { + if (chromium === undefined) { + return; + } + + const dropIns = join(dataDir, "extensions"); + + mkdirSync(dropIns, { recursive: true }); + writeFileSync(join(dropIns, localeFile), localeModule); + const active = await openApp(scriptedModel(), dataDir); + + app = active; + session = await newSession(active); + await active.commands.updateSession(session, owner(active), { title: "Menu" }); + guestToken = active.config.addUser("I18n guest", "guest", [String(session)]).token; + server = createServer( + createHandler({ app: active, listen: { host: "127.0.0.1", port: 0 }, restart: () => {} }), + ); + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + browsers = new Browsers({ + dataDir: mkdtempSync(join(root, "i18n-ui-browser-")), + load: async () => undefined, + save: () => {}, + }); + mobile = await browsers.open(501, { restore: false, viewport: VIEWPORTS.mobile }); + desktop = await browsers.open(502, { restore: false, viewport: VIEWPORTS.desktop }); + await mobile.setViewport(VIEWPORTS.mobile); + await desktop.setViewport(VIEWPORTS.desktop); + + for (const page of [mobile, desktop]) { + await page.navigate(`${base}/login?token=${encodeURIComponent(guestToken)}`); + await page.navigate(`${base}/s/${session}`); + await until( + async () => + JSON.parse( + await page.evaluate( + `return JSON.stringify(document.querySelector(".composer textarea") !== null && document.querySelector(".model-chip") !== null)`, + ), + ) === true, + "the signed-in guest session", + 20_000, + ); + } +}); + +after(async () => { + await browsers?.closeAll({ final: true }); + server?.closeAllConnections(); + server?.close(); + await app?.close(); + cleanUp(); +}); + +type UiState = { + lang: string; + path: string; + timeOrigin: number; + title: string | null; + labels: string[]; + menuButton: string | null; + model: string | null; + draft: string | null; + innerWidth: number; + pageWidth: number; +}; + +/** Read the rendered state from a real Chromium page. */ +async function ui(page: BrowserPage): Promise { + return JSON.parse( + await page.evaluate(` + return JSON.stringify({ + lang: document.documentElement.lang, + path: location.pathname, + timeOrigin: performance.timeOrigin, + title: document.querySelector(".sheet-head h2")?.textContent.trim() ?? null, + labels: [...document.querySelectorAll(".sheet .group .list-item > span:first-child")] + .map((element) => element.textContent.trim()), + menuButton: document.querySelector('.topbar [aria-label="菜单"], .topbar [aria-label="Menu"]') + ?.getAttribute("aria-label") ?? null, + model: document.querySelector(".model-chip")?.textContent.trim() ?? null, + draft: document.querySelector(".composer textarea")?.value ?? null, + innerWidth, + pageWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), + }); + `), + ) as UiState; +} + +async function openMenu(page: BrowserPage): Promise { + await page.evaluate(`document.querySelector('.topbar [aria-label="Menu"]').click()`); + await until(async () => (await ui(page)).title === "Menu", "the open session menu"); +} + +async function postExtension(token: string, enabled: boolean): Promise { + return fetch(`${base}/api/extensions/${localeFile}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-pocket": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); +} + +function assertNoReloadAndNoDataChanges(state: UiState, initial: UiState) { + assert.equal(state.path, initial.path, "the open conversation stays put"); + assert.equal(state.timeOrigin, initial.timeOrigin, "the document was not reloaded"); + assert.equal(state.title, "Menu", "the user's conversation title is not translated"); + assert.equal(state.model, initial.model, "the model identifier is unchanged"); + assert.equal(state.draft, initial.draft, "the unsent draft is preserved"); + assert.ok( + state.pageWidth <= state.innerWidth, + `no horizontal overflow: page ${state.pageWidth}px, viewport ${state.innerWidth}px`, + ); +} + +test( + "live locale hello updates open guest menus on mobile and desktop, then restores English", + real, + async () => { + const active = app; + const phonePage = mobile; + const computerPage = desktop; + + assert.ok(active && phonePage && computerPage); + + await phonePage.type({ selector: ".composer textarea" }, draft); + await openMenu(phonePage); + await openMenu(computerPage); + + const initialMobile = await ui(phonePage); + const initialDesktop = await ui(computerPage); + + for (const [label, state] of [ + ["mobile", initialMobile], + ["desktop", initialDesktop], + ] as const) { + assert.equal(state.lang, "en", `${label} starts in English`); + assert.ok(state.labels.includes("Appearance"), `${label} shows English Appearance`); + assert.ok(state.labels.includes("Extensions"), `${label} shows English Extensions`); + assert.equal(state.title, "Menu", `${label} shows the unmodified user title`); + assert.equal(state.menuButton, "Menu", `${label} has the English Menu control`); + assert.match(state.model ?? "", /faux-1/, `${label} shows the model ID`); + assert.ok( + state.pageWidth <= state.innerWidth, + `${label} has no horizontal overflow (${state.pageWidth}/${state.innerWidth})`, + ); + } + + assert.equal(initialMobile.draft, draft); + assert.equal(initialDesktop.innerWidth, VIEWPORTS.desktop.width); + + const refused = await postExtension(guestToken, true); + + assert.equal(refused.status, 403, "a guest cannot enable a drop-in extension"); + assert.equal( + active.loader.list().find((module) => module.file === localeFile)?.enabled, + false, + "the denied request leaves the fixture disabled", + ); + + const enabled = await postExtension(active.config.ownerToken, true); + + assert.equal(enabled.status, 200, "the owner enables the test locale through the API"); + await until( + async () => { + const [phone, computer] = await Promise.all([ui(phonePage), ui(computerPage)]); + + return ( + phone.lang === "zh-CN" && + phone.labels.includes("扩展") && + phone.labels.includes("外观") && + phone.menuButton === "菜单" && + computer.lang === "zh-CN" && + computer.labels.includes("扩展") && + computer.labels.includes("外观") && + computer.menuButton === "菜单" + ); + }, + "both open guest menus to update to Chinese", + 20_000, + ); + + const chineseMobile = await ui(phonePage); + const chineseDesktop = await ui(computerPage); + + for (const [label, state, initial] of [ + ["mobile", chineseMobile, initialMobile], + ["desktop", chineseDesktop, initialDesktop], + ] as const) { + assert.equal(state.lang, "zh-CN", `${label} document language changes`); + assert.ok(state.labels.includes("扩展"), `${label} translates Extensions`); + assert.ok(state.labels.includes("外观"), `${label} translates Appearance`); + assert.equal(state.menuButton, "菜单", `${label} translates the Menu control`); + assertNoReloadAndNoDataChanges(state, initial); + } + + const disabled = await postExtension(active.config.ownerToken, false); + + assert.equal(disabled.status, 200, "the owner disables the test locale through the API"); + await until( + async () => { + const [phone, computer] = await Promise.all([ui(phonePage), ui(computerPage)]); + + return ( + phone.lang === "en" && + phone.labels.includes("Extensions") && + phone.labels.includes("Appearance") && + phone.menuButton === "Menu" && + computer.lang === "en" && + computer.labels.includes("Extensions") && + computer.labels.includes("Appearance") && + computer.menuButton === "Menu" + ); + }, + "both open guest menus to return to English", + 20_000, + ); + + const englishMobile = await ui(phonePage); + const englishDesktop = await ui(computerPage); + + for (const [label, state, initial] of [ + ["mobile", englishMobile, initialMobile], + ["desktop", englishDesktop, initialDesktop], + ] as const) { + assert.equal(state.lang, "en", `${label} document language returns to English`); + assert.ok(state.labels.includes("Extensions"), `${label} restores Extensions`); + assert.ok(state.labels.includes("Appearance"), `${label} restores Appearance`); + assert.equal(state.menuButton, "Menu", `${label} restores the English Menu control`); + assertNoReloadAndNoDataChanges(state, initial); + } + }, +); diff --git a/test/i18n.test.ts b/test/i18n.test.ts new file mode 100644 index 00000000..e2e7462b --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,65 @@ +// The UI translator accepts only explicit data strings and simple placeholders, never extension scripts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { currentLocale, setUiLocales, t } from "../web/i18n.js"; + +const chinese = { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Extensions: "扩展" }, + templates: { + "No model matches “{{query}}”.": "没有匹配的模型:“{{query}}”。", + }, +}; + +test("English is the default when no extension supplies a locale", () => { + assert.equal(setUiLocales([]), "en"); + assert.equal(currentLocale(), "en"); + assert.equal(t("Extensions"), "Extensions"); +}); + +test("registered locale packs translate explicit strings and retain dynamic content verbatim", () => { + assert.equal(setUiLocales([chinese]), "zh-CN"); + assert.equal(t("Extensions"), "扩展"); + assert.equal( + t("No model matches “{{query}}”.", { query: "faux-2" }), + "没有匹配的模型:“faux-2”。", + ); + assert.equal(t("No model matches “faux-2”."), "No model matches “faux-2”."); + assert.equal(t("A user message that says Extensions"), "A user message that says Extensions"); +}); + +test("parameters are substituted once, unknown and prototype keys fall back, order is deterministic", () => { + setUiLocales([chinese]); + assert.equal( + t("No model matches “{{query}}”.", { query: "{{query}}$&" }), + "没有匹配的模型:“{{query}}$&”。", + ); + assert.equal(t("constructor"), "constructor"); + assert.equal(t("__proto__"), "__proto__"); + assert.equal(t(null), null); + assert.equal(t("Missing {{value}}", { value: "Menu" }), "Missing Menu"); + const french = { + ...chinese, + locale: "fr", + label: "French", + strings: { Extensions: "Extensions FR" }, + }; + + setUiLocales([chinese, french]); + assert.equal(currentLocale(), "fr"); + setUiLocales([french, chinese]); + assert.equal(currentLocale(), "fr"); +}); + +test("removing all locale packs restores the original English strings", () => { + setUiLocales([chinese]); + assert.equal(t("Extensions"), "扩展"); + + setUiLocales([]); + + assert.equal(currentLocale(), "en"); + assert.equal(t("Extensions"), "Extensions"); + assert.equal(t("No model matches “faux-2”."), "No model matches “faux-2”."); +}); diff --git a/test/ui-locales.test.ts b/test/ui-locales.test.ts new file mode 100644 index 00000000..88c4b5ab --- /dev/null +++ b/test/ui-locales.test.ts @@ -0,0 +1,390 @@ +// Extension UI locale metadata: validation and reload behavior in isolated temporary extension/data folders. +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRegistry } from "@earendil-works/pi-durable"; +import { test } from "node:test"; +import type { PocketHost } from "../src/server/host.ts"; +import { ExtensionLoader, prepareDropInFolder } from "../src/server/reload.ts"; + +interface Fixture { + root: string; + dropIn: string; + loader: ExtensionLoader; + registry: ReturnType; + choices: Map; + changes: number; + add(file: string, source: string): void; + enable(file: string): Promise; +} + +async function isolated(run: (fixture: Fixture) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "pocket-ui-locales-")); + const builtIn = join(root, "built-in"); + const dropIn = join(root, "data", "extensions"); + const choices = new Map(); + let changes = 0; + + mkdirSync(builtIn, { recursive: true }); + prepareDropInFolder(dropIn, join(import.meta.dirname, "..", "node_modules")); + + const registry = createRegistry(); + const loader = new ExtensionLoader( + registry, + { notice: () => {} } as unknown as PocketHost, + { builtIn, dropIn }, + (file) => choices.get(file), + async () => { + changes += 1; + }, + ); + const fixture: Fixture = { + root, + dropIn, + loader, + registry, + choices, + get changes() { + return changes; + }, + add: (file, source) => writeFileSync(join(dropIn, file), source), + enable: async (file) => { + choices.set(file, true); + await loader.apply(file); + }, + }; + + try { + await run(fixture); + } finally { + loader.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +function moduleSource(name: string, locales: string): string { + return `import { defineExtension } from "@earendil-works/pi-durable"; +export default () => ({ ...defineExtension({ name: ${JSON.stringify(name)} }), uiLocales: ${locales} });`; +} + +function pack(locale: string, strings = `{ Hello: "${locale}" }`, preferred = false): string { + return `{ locale: ${JSON.stringify(locale)}, label: ${JSON.stringify(locale)}, ${preferred ? "default: true," : ""} strings: ${strings} }`; +} + +async function waitFor(check: () => boolean, what: string): Promise { + const deadline = Date.now() + 5000; + + while (!check()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${what}`); + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +test("locale packs are copied, frozen, and returned as an isolated deterministic snapshot", async () => { + await isolated(async ({ loader, choices, add }) => { + const key = `__pocket_locale_${crypto.randomUUID().replaceAll("-", "")}`; + const name = "copied-locale"; + + add( + "copy.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const packs = [{ locale: "zh-CN", label: "简体中文", default: true, strings: { Hello: "你好" }, templates: { "{{count}}": "{{count}} 项" } }]; +globalThis[${JSON.stringify(key)}] = packs; +export default () => ({ ...defineExtension({ name: ${JSON.stringify(name)} }), uiLocales: packs });`, + ); + choices.set("copy.ts", true); + await loader.apply("copy.ts"); + const installed = (await loader.reload("copy.ts"))[0]!; + + assert.equal(Object.getOwnPropertyDescriptor(installed, "uiLocales")?.writable, false); + + const original = (globalThis as unknown as Record)[key] as { + strings: Record; + }[]; + const exposed = loader.uiLocales(); + const registered = exposed[0]!; + + try { + original[0]!.strings.Hello = "被改写"; + original.push({ strings: {} }); + + assert.equal(loader.uiLocales()[0]?.strings.Hello, "你好"); + assert.equal(Object.isFrozen(registered), true); + assert.equal(Object.isFrozen(registered.strings), true); + assert.equal(Object.isFrozen(registered.templates), true); + assert.equal(Object.isFrozen(loader.uiLocales()[0]?.templates), true); + assert.throws(() => { + (registered.strings as Record).Hello = "改写"; + }, TypeError); + + exposed.pop(); + assert.equal( + loader.uiLocales().length, + 1, + "a caller cannot mutate the loader's collection", + ); + } finally { + delete (globalThis as unknown as Record)[key]; + } + }); +}); + +test("a later registry validation error cannot leave earlier extensions from a reload installed", async () => { + await isolated(async ({ loader, registry, choices, add }) => { + add("existing.ts", moduleSource("existing", `[${pack("en", '{ Existing: "old" }')}]`)); + choices.set("existing.ts", true); + await loader.apply("existing.ts"); + + // Simulate a task collision detected by the real registry after an extension passed loader checks. + const task = { definition: { name: "locale-regression-collision" } } as never; + const colliding = { + name: "already-installed", + tasks: [task], + } as never; + + registry.install(colliding); + + add( + "partial.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const task = { definition: { name: "locale-regression-collision" } }; +export default () => [ + { ...defineExtension({ name: "first-in-partial" }), uiLocales: [${pack("fr", '{ First: "premier" }')}] }, + { ...defineExtension({ name: "second-in-partial" }), tasks: [task], uiLocales: [${pack("de", '{ Second: "zweite" }')}] }, +];`, + ); + choices.set("partial.ts", true); + + await assert.rejects(loader.apply("partial.ts"), /locale-regression-collision/); + assert.equal(registry.snapshot().extension("first-in-partial"), undefined); + assert.equal(registry.snapshot().extension("second-in-partial"), undefined); + assert.deepEqual( + loader.uiLocales().map((locale) => locale.locale), + ["en"], + "the failed module contributes no locale packs", + ); + }); +}); + +test("locale schema rejects noncanonical tags, accessors, sparse arrays, and excessive aggregate packs", async () => { + await isolated(async ({ loader, choices, add }) => { + add("noncanonical.ts", moduleSource("noncanonical", `[${pack("en-us")}]`)); + choices.set("noncanonical.ts", true); + await assert.rejects(loader.apply("noncanonical.ts"), /canonical BCP 47/); + add("empty-locale.ts", moduleSource("empty-locale", `[${pack("")}]`)); + choices.set("empty-locale.ts", true); + await assert.rejects(loader.apply("empty-locale.ts"), /canonical BCP 47/); + + add( + "accessor.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const entry = { locale: "en", label: "English", strings: { Hello: "Hello" } }; +Object.defineProperty(entry, "label", { enumerable: true, get() { globalThis.__localeGetterRan = true; return "English"; } }); +export default () => ({ ...defineExtension({ name: "accessor" }), uiLocales: [entry] });`, + ); + choices.set("accessor.ts", true); + await assert.rejects(loader.apply("accessor.ts"), /enumerable data properties/); + assert.equal( + (globalThis as unknown as Record).__localeGetterRan, + undefined, + ); + delete (globalThis as unknown as Record).__localeGetterRan; + + add("sparse.ts", moduleSource("sparse", "Array(1)")); + choices.set("sparse.ts", true); + await assert.rejects(loader.apply("sparse.ts"), /dense array/); + + add( + "bad-template.ts", + moduleSource( + "bad-template", + '[{ locale: "en", label: "English", strings: {}, templates: { "Hello {{name}}": "Hello" } }]', + ), + ); + choices.set("bad-template.ts", true); + await assert.rejects(loader.apply("bad-template.ts"), /template placeholders must match/); + + const tags = [ + "en", + "fr", + "de", + "es", + "it", + "pt", + "nl", + "sv", + "da", + "no", + "fi", + "is", + "pl", + "cs", + "sk", + "hu", + ]; + + add("many.ts", moduleSource("many", `[${tags.map((locale) => pack(locale)).join(",")}]`)); + choices.set("many.ts", true); + await loader.apply("many.ts"); + add("overflow.ts", moduleSource("overflow", `[${pack("ro")}]`)); + choices.set("overflow.ts", true); + await assert.rejects(loader.apply("overflow.ts"), /maximum 16/); + assert.equal(loader.uiLocales().length, 16); + }); +}); + +test("locale conflicts are explicit and multiple defaults use the smallest canonical tag", async () => { + await isolated(async ({ loader, choices, add }) => { + add("z-locale.ts", moduleSource("z-locale", `[${pack("zh-CN", undefined, true)}]`)); + add("a-locale.ts", moduleSource("a-locale", `[${pack("fr-FR", undefined, true)}]`)); + add("m-locale.ts", moduleSource("m-locale", `[${pack("de-DE")}]`)); + choices.set("z-locale.ts", true); + await loader.apply("z-locale.ts"); + choices.set("a-locale.ts", true); + await loader.apply("a-locale.ts"); + choices.set("m-locale.ts", true); + await loader.apply("m-locale.ts"); + + assert.deepEqual( + loader + .uiLocales() + .map(({ locale, default: preferred }) => [locale, preferred === true]), + [ + ["fr-FR", true], + ["zh-CN", true], + ["de-DE", false], + ], + ); + + add("conflict.ts", moduleSource("conflict", `[${pack("zh-CN")}]`)); + choices.set("conflict.ts", true); + await assert.rejects( + loader.apply("conflict.ts"), + /z-locale\.ts already registers the zh-CN UI locale/, + ); + assert.equal(loader.uiLocales().length, 3); + + add( + "duplicate.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const locale = { locale: "en", label: "English", strings: {} }; +export default () => [ + { ...defineExtension({ name: "duplicate-one" }), uiLocales: [locale] }, + { ...defineExtension({ name: "duplicate-two" }), uiLocales: [locale] }, +];`, + ); + choices.set("duplicate.ts", true); + await assert.rejects( + loader.apply("duplicate.ts"), + /duplicate\.ts registers the en UI locale more than once/, + ); + assert.equal(loader.uiLocales().length, 3); + }); +}); + +test("a malformed reload preserves the last good locale dictionary", async () => { + await isolated(async ({ loader, choices, add }) => { + const file = "translation.ts"; + + add(file, moduleSource("translation", `[${pack("ja-JP", '{ Hello: "こんにちは" }')}]`)); + choices.set(file, true); + await loader.apply(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "こんにちは"); + + add(file, moduleSource("translation", `[${pack("ja-JP", "{ Hello: 7 }")}]`)); + await assert.rejects(loader.reload(file), /values must be plain strings/); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "こんにちは"); + + add(file, moduleSource("translation", `[${pack("ja-JP", '{ Hello: "おはよう" }')}]`)); + await loader.reload(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "おはよう"); + }); +}); + +test("a newer reload wins over an older asynchronous import", async () => { + await isolated(async ({ loader, choices, add, root }) => { + const file = "newer-wins.ts"; + const started = join(root, "old.started"); + const release = join(root, "old.release"); + + add( + file, + `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(started)}, "started"); +while (!existsSync(${JSON.stringify(release)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "newer-wins" }), uiLocales: [{ locale: "fr-FR", label: "Français", strings: { Hello: "Bonjour" } }] });`, + ); + choices.set(file, true); + const olderLoad = loader.apply(file); + + await waitFor(() => existsSync(started), "the older import to begin"); + add(file, moduleSource("newer-wins", `[${pack("es-ES", '{ Hello: "Hola" }')}]`)); + await loader.reload(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "Hola"); + + writeFileSync(release, "release"); + await olderLoad; + assert.equal(loader.uiLocales()[0]?.strings.Hello, "Hola"); + }); +}); + +test("a load finishing after disable or deletion cannot resurrect its locale", async () => { + await isolated(async (fixture) => { + const { loader, choices, add, root, dropIn } = fixture; + const disableFile = "disable-race.ts"; + const disableStarted = join(root, "disable.started"); + const disableRelease = join(root, "disable.release"); + + add( + disableFile, + `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(disableStarted)}, "started"); +while (!existsSync(${JSON.stringify(disableRelease)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "disable-race" }), uiLocales: [{ locale: "sv-SE", label: "Svenska", strings: { Hello: "Hej" } }] });`, + ); + choices.set(disableFile, true); + const enabling = loader.apply(disableFile); + + await waitFor(() => existsSync(disableStarted), "the in-flight extension import"); + choices.set(disableFile, false); + await loader.apply(disableFile); + writeFileSync(disableRelease, "release"); + await enabling; + assert.equal(loader.extensionNames().includes("disable-race"), false); + assert.equal(loader.uiLocales().length, 0); + + const deleteFile = "delete-race.ts"; + const deleteStarted = join(root, "delete.started"); + const deleteRelease = join(root, "delete.release"); + const source = `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(deleteStarted)}, "started"); +while (!existsSync(${JSON.stringify(deleteRelease)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "delete-race" }), uiLocales: [{ locale: "da-DK", label: "Dansk", strings: { Hello: "Hej" } }] });`; + + add(deleteFile, moduleSource("delete-race", `[${pack("da-DK", '{ Hello: "Hej" }')}]`)); + choices.set(deleteFile, true); + await loader.apply(deleteFile); + add(deleteFile, source); + loader.watch(); + const reloading = loader.reload(deleteFile); + + await waitFor(() => existsSync(deleteStarted), "the in-flight reload before deletion"); + rmSync(join(dropIn, deleteFile)); + await waitFor( + () => !loader.extensionNames().includes("delete-race"), + "the deleted extension to uninstall", + ); + writeFileSync(deleteRelease, "release"); + await reloading; + await waitFor(() => loader.uiLocales().length === 0, "deleted locale data to disappear"); + assert.equal(loader.extensionNames().includes("delete-race"), false); + await waitFor(() => fixture.changes > 0, "client refresh after deletion"); + }); +}); diff --git a/web/app.js b/web/app.js index 8d9b2991..7d3a4ed2 100644 --- a/web/app.js +++ b/web/app.js @@ -1,3 +1,4 @@ +import { t } from "./i18n.js"; // Pi Pocket web app. No build step: edit a file under web/ and every open browser reloads. import { render } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; @@ -79,12 +80,12 @@ function ChangesButton() { const removed = files.reduce((sum, file) => sum + (file.removed ?? 0), 0); const lines = added > 0 || removed > 0; const count = files.length + (changes.more ?? 0); - const counted = `${count} ${count === 1 ? "file" : "files"}`; + const counted = t(count === 1 ? "{{count}} file" : "{{count}} files", { count }); return html` @@ -147,7 +152,7 @@ function Topbar() { ${ conversation?.kind === "subagent" ? html` - subagent of ${conversation.parent?.title ?? "?"} + ${t("subagent of {{title}}", { title: conversation.parent?.title ?? "?" })} ` : html`${ folder && @@ -159,13 +164,13 @@ function Topbar() { (branchAvailable() ? html`` - : html` + : html` ⎇ ${branch} `) }` @@ -176,8 +181,8 @@ function Topbar() { busySubagents > 0 && html` + ` } -
${ entries === null - ? html`
Loading…
` + ? html`
${t("Loading…")}
` : entries.length === 0 ? html`
- Nothing yet. Logs, errors, and failed loads show here. + ${t("Nothing yet. Logs, errors, and failed loads show here.")}
` : entries.map((entry) => entry.level === "nav" @@ -627,13 +628,13 @@ function StartScreen({ steer }) { return html`
<${Icon} name="globe" size=${28} />

- ${steer ? "Type an address above, such as localhost:5173, or ask Pi to open one." : "Nothing is open yet. Pi, or someone who can steer, opens pages here."} + ${steer ? t("Type an address above, such as {{address}}, or ask Pi to open one.", { address: "localhost:5173" }) : t("Nothing is open yet. Pi, or someone who can steer, opens pages here.")}

- ${owner && servers === null && html`<${Spinner} label="Looking for servers" />`} + ${owner && servers === null && html`<${Spinner} label=${t("Looking for servers")} />`} ${ servers?.length > 0 && html`
-
Running on this machine
+
${t("Running on this machine")}
${servers.slice(0, 8).map( (server) => html`
`; @@ -813,19 +814,19 @@ export function BrowserPanel({ leaving = false }) { } else if (!open && why) { stage = html`

${why}

- ${steer && html``} + ${steer && html``}
`; } else if (!open && !steer) { stage = html`
<${StartScreen} steer=${false} />
`; } else if (waiting) { stage = html`
-

The page closed.

- +

${t("The page closed.")}

+
`; } else if (!open || !frame) { stage = html`
- <${Spinner} label="Starting the browser" /> - Starting the browser… + <${Spinner} label=${t("Starting the browser")} /> + ${t("Starting the browser…")}
`; } else { stage = html`
@@ -838,7 +839,7 @@ export function BrowserPanel({ leaving = false }) { ${ steer && html`` }
` @@ -848,15 +849,15 @@ export function BrowserPanel({ leaving = false }) { return html`
<${ResizeEdge} />
` : html` ` }
` } @@ -399,14 +424,14 @@ function ChatTab({ highlight, focus }) { (person) => html` <${Avatar} person=${person} size=${18} /> ${person.name} - ${person.id === me?.id ? " (you)" : person.away ? " · away" : ""} + ${person.id === me?.id ? ` (${t("you")})` : person.away ? ` · ${t("away")}` : ""} `, )}
${ chat.length === 0 && - html`
No messages yet. Pi does not see this chat.
` + html`
${t("No messages yet. Pi does not see this chat.")}
` } ${chat.map((message, index) => { const previous = chat[index - 1]; @@ -426,6 +451,7 @@ function ChatTab({ highlight, focus }) { users=${users} me=${me} steer=${canSteer()} + localeRevision=${store.state.uiLocaleRevision} onSelect=${setSelected} />`; })} @@ -434,10 +460,10 @@ function ChatTab({ highlight, focus }) { ${ chatQuote && html`
- Discussing ${chatQuote.text} + ${t("Discussing")} ${chatQuote.text} · + ${t("Use theirs")} + ·
` }
- ${by ? `Saved by ${by} ${timeAgo(notes.at)}` : "Not saved yet"} - ${editing ? " · unsaved changes" : ""} + ${by ? t("Saved by {{name}} {{when}}", { name: by, when: timeAgo(notes.at) }) : t("Not saved yet")} + ${editing ? ` · ${t("unsaved changes")}` : ""} ${ canSteer() && @@ -625,14 +651,14 @@ function NotesTab() { } ${ editing && - html`` + html`` }
`; } @@ -646,13 +672,13 @@ function PeopleTabs({ ask, focus }) { const tabs = collab() ? html`
` : null; @@ -678,7 +704,7 @@ export function ChatSheet() { // While the sheet animates out, the store has no sheet any more. const sheet = store.state.sheet ?? {}; - return html`<${Sheet} title="People" onClose=${closeSheet}> + return html`<${Sheet} title=${t("People")} onClose=${closeSheet}> <${PeopleTabs} ask=${sheet} focus=${true} /> `; } @@ -714,16 +740,16 @@ export function PeoplePanel({ leaving = false }) { return html`
-

People

+

${t("People")}

+ ${name(id)} asks to drive `, )} ${ others.length > 0 && html`` } - + ${ handing && html`
@@ -205,7 +206,7 @@ function DriverBar() { ${ canTake && html`` }
`; @@ -224,20 +225,20 @@ function PlanBar({ blocked }) { return html`
- Plan mode. ${subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve."} + ${t("Plan mode.")} ${subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve."} ${ !blocked && !subagent && html` ` }
`; @@ -574,7 +575,7 @@ export function Composer() { const command = full.replace(/^!!?/, "").trim(); if (command === "") { - return notify("info", "Say which command to run, such as !git status."); + return notify("info", t("Say which command to run, such as !git status.")); } setSending(true); @@ -862,7 +863,7 @@ export function Composer() { ), ), (error) => { - notify("error", `Upload failed: ${error.message}`); + notify("error", `${t("Upload failed: ")}${error.message}`); if (preview) { URL.revokeObjectURL(preview); @@ -909,12 +910,12 @@ export function Composer() { }; const placeholder = dictation.listening - ? "Listening…" + ? t("Listening…") : busy ? steer - ? "Steer the current run…" - : "Queue a message for after this run…" - : "Ask Pi anything…"; + ? t("Steer the current run…") + : t("Queue a message for after this run…") + : t("Ask Pi anything…"); const inbox = view.inbox ?? []; const { me, users } = store.state; const queuedBy = (item) => @@ -943,10 +944,10 @@ export function Composer() { <${ParentSessionButton} />
- View only. You can read along, react, and chat with the people here. + ${t("View only.")} ${t("You can read along, react, and chat with the people here.")}
<${StatusLine} /> @@ -985,8 +986,8 @@ export function Composer() { (!blocked || item.by === me?.id) && html``, )} - ${matches.length === 0 && html`
Finding files…
`} + ${matches.length === 0 && html`
${t("Finding files…")}
`} ${ found.truncated && html`
@@ -1199,7 +1200,7 @@ export function Composer() { ${file.state === "uploading" ? html`<${Spinner} />` : file.preview ? "" : "📎"} ${file.name} ${formatBytes(file.size)} ` } ${ @@ -1275,7 +1276,7 @@ export function Composer() { html`
{ // Arrows move between the two, as in any group of radio buttons. if (/^Arrow(Left|Right|Up|Down)$/.test(event.key)) { @@ -1298,10 +1299,10 @@ export function Composer() { aria-checked=${steer === value ? "true" : "false"} tabindex=${steer === value ? "0" : "-1"} class=${steer === value ? "on" : ""} - title=${tip} + title=${t(tip)} onClick=${() => setSteer(value)} > - ${label} + ${t(label)} `, )}
` @@ -1311,8 +1312,8 @@ export function Composer() { <${MicButton} dictation=${dictation} class="composer-mic" /> ` : html`${ !first && html`` } ${ !last && html` + `; } @@ -610,15 +611,15 @@ function DiffFile({ if (loading) { body = null; } else if (file.binary) { - body = html`

A binary file: no lines to compare.

`; + body = html`

${t("A binary file: no lines to compare.")}

`; } else if (file.hunks.length === 0) { body = html`

- ${file.kind === "renamed" ? "Renamed, with no change to its text." : file.mode ? "Only its mode changed." : "No difference in text."} + ${t(file.kind === "renamed" ? "Renamed, with no change to its text." : file.mode ? "Only its mode changed." : "No difference in text.")}

`; } else if (!drawn) { body = html`

- ${`A large diff: ${size.toLocaleString()} changed lines. `} - + ${t("A large diff: {{count}} changed lines.", { count: size.toLocaleString() })} +

`; } else { body = html`
@@ -626,14 +627,14 @@ function DiffFile({ ${ file.truncated && html`

- ${"The diff is too long to show whole: the rest is left out."} + ${t("The diff is too long to show whole: the rest is left out.")}

` } ${ canExpand && !matches && html`

- ${fetched === false ? "This file is too big to show its other lines here." : "The file changed after this diff was read, so its other lines cannot be shown. Refresh to see them."} + ${t(fetched === false ? "This file is too big to show its other lines here." : "The file changed after this diff was read, so its other lines cannot be shown. Refresh to see them.")}

` }
`; @@ -692,7 +693,7 @@ function FileHead({ file, tools, viewed, onViewed, open, onToggle, note }) { class="dv-fold" type="button" aria-expanded=${open ? "true" : "false"} - aria-label=${open ? "Fold" : "Unfold"} + aria-label=${t(open ? "Fold" : "Unfold")} onClick=${onToggle} > <${Icon} name="chevron" size=${14} class=${`chev ${open ? "open" : ""}`} /> @@ -710,13 +711,13 @@ function FileHead({ file, tools, viewed, onViewed, open, onToggle, note }) { ${tools} ${ onViewed && - html`
`; @@ -754,15 +755,15 @@ function DiffSettings() { `, )}`; @@ -1187,8 +1188,8 @@ function ReviewFile({ notify( "info", fresh - ? `Deleted ${file.path}.` - : `${file.path} is as the last commit has it.`, + ? t("Deleted {{path}}.", { path: file.path }) + : t("{{path}} is as the last commit has it.", { path: file.path }), ); onChanged(); }) @@ -1199,8 +1200,8 @@ function ReviewFile({ html`` }`; @@ -1232,9 +1233,9 @@ function ReviewFile({ onViewed=${(on) => onViewed(file.path, on ? (entry?.print ?? "?") : undefined)} open=${open && !viewed} onToggle=${onToggle} - note=${html`${file.byPi && html`Pi`}${stale && html`changed`}`} + note=${html`${file.byPi && html`Pi`}${stale && html`${t("changed")}`}`} /> - ${open && !viewed && !entry && !error && html`
<${Loader} label="Reading the diff" />
`} + ${open && !viewed && !entry && !error && html`
<${Loader} label=${t("Reading the diff")} />
`} ${error && html`

${error}

`}
`; } @@ -1433,7 +1434,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false const next = nextLayout(diffPrefs.layout); setDiffPrefs({ layout: next }); - notify("info", `Diffs: ${next}.`); + notify("info", `${t("Diffs: ")}${next}.`); } else if (key === "w") { setDiffPrefs({ wrap: !diffPrefs.wrap }); } else { @@ -1448,7 +1449,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false } if (!changes) { - return html`
<${Loader} label="Asking git" />
`; + return html`
<${Loader} label=${t("Asking git")} />
`; } const all = changes.files; @@ -1467,32 +1468,32 @@ export function DiffReview({ active = true, autoFocus = false, covering = false ? html`` - : html`⎇ ${changes.repo.branch}`) + : html`⎇ ${changes.repo.branch}`) }
` - : html`Not a git repository: only Pi's edits are listed.` + : html`${t("Not a git repository: only Pi's edits are listed.")}` } ${ all.length > 0 && html` - ${files.length} ${files.length === 1 ? "file" : "files"} + ${files.length} ${t("files")} <${ChangeCounts} added=${added} removed=${removed} /> - + - ${seen}/${files.length} viewed + ${t("{{seen}}/{{total}} viewed", { seen, total: files.length })} ` } ` } <${DiffSettings} /> @@ -1531,7 +1532,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false class=${`chip toggle ${treeOpen ? "on" : ""}`} onClick=${() => setTreeOpen(!treeOpen)} > - Files + ${t("Files")} ` } ` @@ -1541,7 +1542,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false all.length === 0 && html`
<${Icon} name="check" size=${22} /> -

No uncommitted changes${changes.repo.branch ? ` on ${changes.repo.branch}` : ""}.

+

${t("No uncommitted changes{{branch}}.", { branch: changes.repo.branch ? ` ${t("on branch")} ${changes.repo.branch}` : "" })}

` } ${ @@ -1549,7 +1550,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false html`
${ (wide || treeOpen) && - html`
@@ -220,13 +221,13 @@ export function CwdSheet({ mode }) { <${Icon} name="chevron" size=${14} /> `, )} - ${listing.dirs.length === 0 && html`
No folders here.
`} + ${listing.dirs.length === 0 && html`
${t("No folders here.")}
`} ` } ${ listing?.recent?.length > 0 && html`
-
Recent
+
${t("Recent")}
${listing.recent.map( (dir) => html` ${ @@ -192,7 +196,7 @@ export function ModelPicker() { <${Icon} name="search" size=${15} /> { setQuery(event.currentTarget.value); @@ -201,17 +205,17 @@ export function ModelPicker() { /> ` } -
+
${ models.length === 0 && html`

- No models are available. Add a provider first. + ${t("No models are available. Add a provider first.")}

` } ${ models.length > 0 && shown.length === 0 && - html`

No model matches “${query.trim()}”.

` + html`

${t("No model matches “")}${query.trim()}”.

` } ${shown.map( (model, index) => html`
- ${ROLE_TEXT[role]}.${role === "guest" && only ? " Seeing one session in the app does not limit what Pi can reach on the machine." : ""} + ${t(ROLE_TEXT[role])}.${role === "guest" && only ? ` ${t("Seeing one session in the app does not limit what Pi can reach on the machine.")}` : ""}
${ here && html`
-
In
+
${t("In")}
` @@ -182,7 +183,7 @@ export function InviteSheet({ session = null }) { ${ !asOwner && html`
-
Expires after
+
${t("Expires after")}
${LASTS.map( ([each, label]) => html``, )}
@@ -199,13 +200,18 @@ export function InviteSheet({ session = null }) {

${ asOwner - ? "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes." - : `Scan this on the other device, or send it the link. It works once and expires ${lasting}. Whoever joins sees ${where}.` + ? t( + "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes.", + ) + : t( + "Scan this on the other device, or send it the link. It works once and expires {{lasting}}. Whoever joins sees {{where}}.", + { lasting, where }, + ) }

${ invite?.access?.url && - html`

Other devices connect through ${invite.access.label}.

` + html`

${t("Other devices connect through {{label}}.", { label: invite.access.label })}

` } ${ invite @@ -213,22 +219,22 @@ export function InviteSheet({ session = null }) { ${ invite.local && html`
- This link only works on this device. To let other devices in, press a in the Pi Pocket terminal (or start it with --access) and pick Local network, Cloudflare Tunnel, or Tailscale. Then make a new invite. + ${t("This link only works on this device. To let other devices in, press {{key}} in the Pi Pocket terminal (or start it with {{flag}}) and pick {{choices}}. Then make a new invite.", { key: "a", flag: "--access", choices: t("Local network, Cloudflare Tunnel, or Tailscale") })}
` }
- Invite code + ${t("Invite code")}
${invite.code.slice(5)}
- Or enter it on the other device's sign-in screen. + ${t("Or enter it on the other device's sign-in screen.")}
@@ -253,11 +259,11 @@ export function InviteSheet({ session = null }) { class="button" onClick=${() => copyText(invite.url).then( - () => notify("info", "Link copied."), - () => notify("error", "Could not copy."), + () => notify("info", t("Link copied.")), + () => notify("error", t("Could not copy.")), )} > - Copy + ${t("Copy")}
${ @@ -270,24 +276,29 @@ export function InviteSheet({ session = null }) { }
` : failed - ? html`

No invite yet. Tap New invite to try again.

` - : html`<${Loader} label="Making an invite" />` + ? html`

${t("No invite yet. Tap New invite to try again.")}

` + : html`<${Loader} label=${t("Making an invite")} />` } - + ${!collab() && html`<${PeopleList} />`} `; } function lastSeen(person) { if (person.online) { - return "here now"; + return t("here now"); } if (!person.lastSeen) { - return "not seen yet"; + return t("not seen yet"); } - return `seen ${timeAgo(person.lastSeen)}${timeAgo(person.lastSeen) === "now" ? "" : " ago"}`; + return t("seen {{time}}", { + time: + timeAgo(person.lastSeen) === "now" + ? t("now") + : t("{{time}} ago", { time: timeAgo(person.lastSeen) }), + }); } /** Everyone with access: who is online, when the others were last here, and (for the owner) what each may do. */ @@ -304,7 +315,7 @@ function PeopleList() { attempt(async () => store.set({ users: await actions.setAccess(person.id, patch) })); return html`
-
People
+
${t("People")}
${ordered.map( (person) => html`
@@ -312,11 +323,11 @@ function PeopleList() {
${person.name} - ${person.id === me?.id ? html` (you)` : ""} + ${person.id === me?.id ? html` (${t("you")})` : ""}
- ${person.role === "owner" ? "owner" : person.role === "viewer" ? "view only" : "can steer"} - ${person.sessions ? ` · only ${person.sessions.map(sessionTitle).join(", ")}` : ""} · ${lastSeen(person)} + ${t(person.role === "owner" ? "owner" : person.role === "viewer" ? "view only" : "can steer")} + ${person.sessions ? ` · ${t("only {{sessions}}", { sessions: person.sessions.map(sessionTitle).join(", ") })}` : ""} · ${lastSeen(person)}
${ @@ -327,10 +338,10 @@ function PeopleList() { collab() && html`` } ${ @@ -338,16 +349,20 @@ function PeopleList() { person.sessions && html`` }
` } @@ -369,16 +384,16 @@ function PeopleList() { export function PeopleSheet() { const canInvite = canSteer() && !scoped(); - return html`<${Sheet} title="People" onClose=${closeSheet}> + return html`<${Sheet} title=${t("People")} onClose=${closeSheet}> ${ canInvite && html`` } <${PeopleList} /> `; } diff --git a/web/sheets/providers.js b/web/sheets/providers.js index 690b299b..7d71d621 100644 --- a/web/sheets/providers.js +++ b/web/sheets/providers.js @@ -1,6 +1,7 @@ // Model providers: signing in and out, the models subagents may use besides their session's own, and the dialog a // sign-in uses to ask for a code or a key. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, closeSheet, closeSignIn, signInAnswered, store } from "../store.js"; import { html, Icon, Loader, Sheet } from "../ui.js"; @@ -152,7 +153,9 @@ export function ProvidersSheet() { attempt(() => api(`providers/${encodeURIComponent(provider.id)}/login`, { type })); const logout = (provider) => confirm( - `Log out of ${provider.name}? Pi uses the same sign-in, so it is signed out too.`, + t("Log out of {{provider}}? Pi uses the same sign-in, so it is signed out too.", { + provider: provider.name, + }), ) && attempt(async () => { await api(`providers/${encodeURIComponent(provider.id)}/logout`, {}); @@ -171,9 +174,9 @@ export function ProvidersSheet() { a.name.localeCompare(b.name), ); - return html`<${Sheet} title="Providers" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Providers")} onClose=${closeSheet}>

- Pi Pocket shares Pi's sign-ins (~/.pi/agent/auth.json). ${owner ? "" : "Only the owner can change them."} + ${t("Pi Pocket shares Pi's sign-ins")} (~/.pi/agent/auth.json). ${owner ? "" : t("Only the owner can change them.")}

${ owner && @@ -184,12 +187,12 @@ export function ProvidersSheet() { - ${providers === null && html`<${Loader} label="Loading providers" />`} + ${providers === null && html`<${Loader} label=${t("Loading providers")} />`} ${list.map( (provider) => html`
@@ -202,7 +205,7 @@ export function ProvidersSheet() { }
- ${provider.needsSignIn ? "the sign-in expired and could not be renewed: sign in again. Until then, sessions on its models fail, and PiP and subagents on other models do not use them" : provider.configured ? `signed in${provider.label ? ` · ${provider.label}` : provider.source ? ` · ${provider.source}` : ""}` : "not configured"} · ${provider.models} models + ${provider.needsSignIn ? "the sign-in expired and could not be renewed: sign in again. Until then, sessions on its models fail, and PiP and subagents on other models do not use them" : provider.configured ? `${t("signed in")}${provider.label ? ` · ${provider.label}` : provider.source ? ` · ${provider.source}` : ""}` : t("not configured")} · ${t("{{count}} models", { count: provider.models })}
${ provider.needsSignIn && @@ -218,7 +221,7 @@ export function ProvidersSheet() { class="button small" onClick=${() => login(provider, "oauth")} > - ${provider.oauth} + ${provider.oauth === "Sign in with ChatGPT" ? t("Sign in with ChatGPT") : provider.oauth} ` } ${ @@ -227,7 +230,7 @@ export function ProvidersSheet() { class="button small" onClick=${() => login(provider, "api_key")} > - API key + ${t("API key")} ` } ${ @@ -237,7 +240,7 @@ export function ProvidersSheet() { class="button small ghost" onClick=${() => logout(provider)} > - Log out + ${t("Log out")} ` }
` @@ -278,12 +281,12 @@ export function AuthDialog() { const prompt = auth.prompt; - return html`<${Sheet} title=${`Sign in: ${auth.providerName}`} onClose=${close}> + return html`<${Sheet} title=${t("Sign in: {{provider}}", { provider: auth.providerName })} onClose=${close}> ${auth.events.map((event) => { if (event.type === "auth_url") { return html`
- Open the sign-in page + ${t("Open the sign-in page")} ${event.instructions && html`

${event.instructions}

`}
`; @@ -292,7 +295,7 @@ export function AuthDialog() { if (event.type === "device_code") { return html`

- Enter this code at ${event.verificationUri} + ${t("Enter this code at")} ${event.verificationUri}

${event.userCode}
`; @@ -335,12 +338,12 @@ export function AuthDialog() { onKeyDown=${(event) => event.key === "Enter" && answer({ value })} />
`) } - ${!prompt && !auth.done && html`<${Loader} label="Waiting for the provider" />`} + ${!prompt && !auth.done && html`<${Loader} label=${t("Waiting for the provider")} />`} ${auth.done && !auth.done.ok && html`
${auth.done.error}
`} `; } diff --git a/web/sheets/running.js b/web/sheets/running.js index 23cf2ec3..0311b682 100644 --- a/web/sheets/running.js +++ b/web/sheets/running.js @@ -1,5 +1,6 @@ // Running now: everything Pi is doing, across sessions. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, canSteer, closeSheet, navigate, notify } from "../store.js"; import { html, Loader, Sheet, Spinner } from "../ui.js"; @@ -33,9 +34,9 @@ export function RunningSheet() { navigate(id); }; - return html`<${Sheet} title="Running now" onClose=${closeSheet}> - ${sessions === null && html`<${Loader} label="Looking" />`} - ${sessions?.length === 0 && html`

Nothing is running.

`} + return html`<${Sheet} title=${t("Running now")} onClose=${closeSheet}> + ${sessions === null && html`<${Loader} label=${t("Looking")} />`} + ${sessions?.length === 0 && html`

${t("Nothing is running.")}

`} ${sessions?.map( (session) => html`
@@ -55,7 +56,7 @@ export function RunningSheet() { class="button small" onClick=${() => attempt(() => api(`c/${session.id}/abort`, {}))} > - Stop + ${t("Stop")} ` }
@@ -77,7 +78,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/schedules/${encodeURIComponent(task.scheduleId)}/cancel`, {}))} > - Cancel + ${t("Cancel")} ` } ${ @@ -88,7 +89,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/abort`, {}))} > - Stop + ${t("Stop")} ` } ${ @@ -98,7 +99,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/shell/${task.id}/stop`, {}))} > - Stop + ${t("Stop")} ` } ${task.status} diff --git a/web/sheets/schedules.js b/web/sheets/schedules.js index 250eec8d..a0deb241 100644 --- a/web/sheets/schedules.js +++ b/web/sheets/schedules.js @@ -1,5 +1,6 @@ // Scheduled messages of this session. import { useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { actions, attempt, canSteer, closeSheet, store } from "../store.js"; import { formatWhen, html, Sheet } from "../ui.js"; @@ -10,44 +11,44 @@ export function SchedulesSheet() { const steer = canSteer(); const setBy = (id) => id === undefined - ? "Pi" + ? t("Pi") : id === me?.id - ? "you" - : (users.find((user) => user.id === id)?.name ?? "someone"); + ? t("you") + : (users.find((user) => user.id === id)?.name ?? t("someone")); const add = () => attempt(async () => { await actions.schedule(when); setWhen(""); }); - return html`<${Sheet} title="Scheduled messages" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Scheduled messages")} onClose=${closeSheet}>

- Pi gets these at their time, also when nobody is here, and the people in this session get a notification when it is done. + ${t("Pi gets these at their time, also when nobody is here, and the people in this session get a notification when it is done.")}

${ steer && html`
setWhen(event.currentTarget.value)} onKeyDown=${(event) => event.key === "Enter" && add()} />

- Start with when: in 30m, 7:00, tomorrow 9am, fri 17:30, every 2h, every weekday 8:00. Then what Pi gets. + ${t("Start with when: in 30m, 7:00, tomorrow 9am, fri 17:30, every 2h, every weekday 8:00. Then what Pi gets.")}

` } - ${view.schedules.length === 0 && html`

Nothing is scheduled.

`} + ${view.schedules.length === 0 && html`

${t("Nothing is scheduled.")}

`} ${view.schedules.map( (schedule) => html`
${schedule.text}
- ${schedule.repeat ? `${schedule.repeat} · next ${formatWhen(schedule.next)}` : formatWhen(schedule.next)} · set by ${setBy(schedule.by)} + ${schedule.repeat ? `${schedule.repeat} · ${t("next")} ${formatWhen(schedule.next)}` : formatWhen(schedule.next)} · ${t("set by")} ${setBy(schedule.by)}
${ @@ -56,7 +57,7 @@ export function SchedulesSheet() { class="button small ghost" onClick=${() => attempt(() => actions.cancelSchedule(schedule.id))} > - Cancel + ${t("Cancel")} ` }
`, diff --git a/web/sheets/shortcuts.js b/web/sheets/shortcuts.js index ea330cf4..99e7d561 100644 --- a/web/sheets/shortcuts.js +++ b/web/sheets/shortcuts.js @@ -1,5 +1,6 @@ // The keyboard shortcuts, as a list. The keys themselves are handled in app.js. import { closeSheet } from "../store.js"; +import { t } from "../i18n.js"; import { html, Keys, Sheet } from "../ui.js"; const SHORTCUTS = [ @@ -33,15 +34,15 @@ const SHORTCUTS = [ ]; export function ShortcutsSheet() { - return html`<${Sheet} title="Keyboard shortcuts" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Keyboard shortcuts")} onClose=${closeSheet}>
${SHORTCUTS.map( ([keys, text]) => html`
<${Keys} keys=${keys} />
-
${text}
`, +
${t(text)}
`, )}

- In the launcher, start with > for actions, @ for sessions, or # for themes. Arrowing onto a theme shows it; Enter keeps it. + ${t("In the launcher, start with > for actions, @ for sessions, or # for themes. Arrowing onto a theme shows it; Enter keeps it.")}

`; } diff --git a/web/sheets/spend.js b/web/sheets/spend.js index 63a65364..f521f7a8 100644 --- a/web/sheets/spend.js +++ b/web/sheets/spend.js @@ -1,5 +1,6 @@ // Spend: what each session and person cost, and their limits. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, closeSheet, store } from "../store.js"; import { html, Loader, Sheet } from "../ui.js"; @@ -11,7 +12,7 @@ function SpendLimit({ budget, editable, save }) { if (draft === null) { return html` - ${budget === undefined ? "no limit" : `limit ${money(budget)}`} + ${budget === undefined ? t("no limit") : t("limit {{amount}}", { amount: money(budget) })} ${ editable && @@ -19,7 +20,7 @@ function SpendLimit({ budget, editable, save }) { class="link small" onClick=${() => setDraft(budget === undefined ? "" : String(budget))} > - Change + ${t("Change")} ` }`; } @@ -37,11 +38,11 @@ function SpendLimit({ budget, editable, save }) { disabled=${!(Number(draft) > 0)} onClick=${() => done(Number(draft))} > - Set + ${t("Set")} ${ budget !== undefined && - html`` + html`` } `; } @@ -56,19 +57,20 @@ export function SpendSheet() { }, []); const save = (target) => async (budget) => setData(await api("spend", { ...target, budget })); - return html`<${Sheet} title="Spend" onClose=${closeSheet}> - ${data === null && html`<${Loader} label="Adding it up" />`} + return html`<${Sheet} title=${t("Spend")} onClose=${closeSheet}> + ${data === null && html`<${Loader} label=${t("Adding it up")} />`} ${ data?.total !== undefined && - html`

Pi spent ${money(data.total)} on this server so far.

` + html`

${t("Pi spent {{amount}} on this server so far.", { amount: money(data.total) })}

` }

- Spend goes to whoever asked for the work. Past a limit, Pi takes no new messages there, a run that crosses it stops, and subagents' reports wait until it is raised. + ${t("Spend goes to whoever asked for the work. Past a limit, Pi takes no new messages there, a run that crosses it stops, and subagents' reports wait until it is raised.")} +

${ data && html`
-
People
+
${t("People")}
${data.people.map( (person) => html`
${person.name} @@ -82,7 +84,7 @@ export function SpendSheet() { )}
-
Sessions
+
${t("Sessions")}
${data.sessions.map( (session) => html`
${session.title} diff --git a/web/sheets/text.js b/web/sheets/text.js index e8a7196d..e607258b 100644 --- a/web/sheets/text.js +++ b/web/sheets/text.js @@ -1,5 +1,6 @@ // One text field and a button: renaming, resetting, instructions, compacting, and your name. import { useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { attempt, closeSheet } from "../store.js"; import { html, Sheet } from "../ui.js"; @@ -11,7 +12,7 @@ export function TextSheet({ placeholder = "", submit, multiline = false, - button = "Save", + button = t("Save"), }) { const [value, setValue] = useState(initial); const save = () => diff --git a/web/sheets/viewers.js b/web/sheets/viewers.js index 4bc672a7..168bcac2 100644 --- a/web/sheets/viewers.js +++ b/web/sheets/viewers.js @@ -1,5 +1,6 @@ // Artifacts and images, each in a viewer of its own. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { closeSheet, openSheet, store } from "../store.js"; import { modeOf, paletteOf, themeVars } from "../theme.js"; import { formatBytes, html, Icon, Sheet, timeAgo } from "../ui.js"; @@ -29,11 +30,11 @@ export function ArtifactsSheet() { (a, b) => (b.versions.at(-1)?.createdAt ?? 0) - (a.versions.at(-1)?.createdAt ?? 0), ); - return html`<${Sheet} title="Artifacts" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Artifacts")} onClose=${closeSheet}> ${ items.length === 0 && html`

- No artifacts yet. Ask Pi to build something you can look at: a chart, a demo, a game. + ${t("No artifacts yet. Ask Pi to build something you can look at: a chart, a demo, a game.")}

` } ${items.map((artifact) => @@ -71,14 +72,14 @@ export function ArtifactViewer({ id, version }) { ${artifact?.title ?? id} ${artifact?.type ?? ""} · version ${shown} - ${latest && shown !== latest ? html` · ` : ""} + ${latest && shown !== latest ? html` · ` : ""}
${ artifact && artifact.versions.length > 1 && html`` } - - Open tab - @@ -120,14 +121,14 @@ export function ImageViewer({ src, alt }) { return html`
@@ -410,7 +414,7 @@ function Collapsible({ text, limit = 600 }) { return html`
<${Markdown} text=${open ? text : `${text.slice(0, limit)}…`} />
`; } @@ -635,10 +639,10 @@ function ThoughtStep({ block, streaming }) {
${ @@ -724,7 +728,7 @@ function ToolStep({ class="link small tool-open" onClick=${() => openFile(`${args.path}${at}`)} > - Open ${args.path.split("/").pop()} → + ${t("Open")} ${args.path.split("/").pop()} → `, ); } @@ -830,7 +834,9 @@ function ToolStep({ // A call still streaming has no stored entry to load yet. if (clipped && entryId !== undefined) { - parts.push(html``); + parts.push( + html``, + ); } body = html`
${parts}
`; @@ -890,8 +896,10 @@ function ToolStep({ child !== undefined && html`` @@ -899,8 +907,8 @@ function ToolStep({
${ decision && - html`
- <${Icon} name="shield" size=${12} /> ${decision.allow ? "Allowed" : "Denied"} by ${decision.by} + html`
+ <${Icon} name="shield" size=${12} /> ${t("{{decision}} by {{name}}", { decision: t(decision.allow ? "Allowed" : "Denied"), name: decision.by })}
` } ${ @@ -915,7 +923,7 @@ function ToolStep({ html`<${EntryImages} entryId=${result.id} count=${result.images} - label=${`${call.name} image`} + label=${t("{{tool}} image", { tool: call.name })} />` } ${ @@ -939,13 +947,13 @@ function ToolStep({ class="artifact-link" onClick=${() => openSheet({ type: "viewer", id: artifact.id, version: artifact.version })} > - Open ${artifact.title} · version ${artifact.version} → + ${t("Open")} ${artifact.title} · ${t("version {{version}}", { version: artifact.version })} → ` } ${ watch && html`` }
` @@ -1200,22 +1208,22 @@ function ApprovalCard({ approval }) { return html`
- <${Icon} name="shield" size=${16} /> Lancet Guard asks before this ${approval.tool} call + <${Icon} name="shield" size=${16} /> ${t("Lancet Guard asks before this {{tool}} call", { tool: approval.tool })}
${approval.reason}
${approval.subject}
${ !canSteer() - ? html`
Waiting for someone who can steer to answer.
` + ? html`
${t("Waiting for someone who can steer to answer.")}
` : html`${ ownCall && html`
- Someone else has to allow this: it came from your message. You can deny it. + ${t("Someone else has to allow this: it came from your message. You can deny it.")}
` }
${ !ownCall && @@ -1224,7 +1232,7 @@ function ApprovalCard({ approval }) { disabled=${busy} onClick=${() => answer(true)} > - Allow + ${t("Allow")} ` }
` @@ -1267,8 +1275,8 @@ function AnswerActions({ entry }) {
`; } @@ -1321,9 +1329,9 @@ function AnswerEnd({ entry }) { return html`${ entry.stopReason === "error" && - html`
${entry.error ?? "The model request failed."}
` + html`
${entry.error ?? t("The model request failed.")}
` } - ${entry.stopReason === "aborted" && html`
Stopped.
`} + ${entry.stopReason === "aborted" && html`
${t("Stopped.")}
`} ${answer && html`<${AnswerActions} entry=${entry} />`}`; } @@ -1355,14 +1363,14 @@ function Divider({ entry }) { // A session continued from Pi in the terminal: what is above is Pi's, from that session's file. if (entry.kind === "fromPi") { return html`
- Continued from Pi in the terminal + ${t("Continued from Pi in the terminal")}
${entry.file}
`; } if (entry.kind === "reset") { return html`
- New context + ${t("New context")} ${entry.text && html`
<${Markdown} text=${entry.text} />
`}
`; } @@ -1390,7 +1398,7 @@ function History({ firstId, results, keepPlace, conversationId }) { store.set({ history: earlier }); })} > - Show earlier messages + ${t("Show earlier messages")} `; } @@ -1454,7 +1462,7 @@ function ShellEntry({ entry }) { ${ (long || longCommand) && html`` } ${ @@ -1468,7 +1476,7 @@ function ShellEntry({ entry }) { setOpen(true); })} > - Load everything + ${t("Load everything")} ` } @@ -1478,7 +1486,7 @@ function ShellEntry({ entry }) { /** Something a person did that Pi was told about, such as undoing a file. */ function NoteEntry({ entry }) { return html`
- ${entry.name} ${entry.text}. Pi was told. + ${entry.name} ${entry.text}. ${t("Pi was told.")}
`; } @@ -1532,7 +1540,7 @@ function PendingShells({ conversationId, rows }) { class="link small" onClick=${() => attempt(() => actions.stopShell(each.taskId))} > - Stop + ${t("Stop")} @@ -1582,7 +1590,15 @@ const agentsKey = (view) => */ function rowDeps(entry) { const { view, users, me, server } = store.state; - const deps = [entry, users, me, server, view.conversation?.id, view.conversation?.kind]; + const deps = [ + entry, + users, + me, + server, + view.conversation?.id, + view.conversation?.kind, + store.state.uiLocaleRevision, + ]; if (entry.kind === "user") { deps.push(view.authors?.[entry.id]); @@ -1864,7 +1880,7 @@ function LiveLine({ live, approvals, start }) { : html`<${Spinner} hidden=${true} />` } - ${state}${retry && !waiting && seconds > 0 && html``} + ${t(state)}${retry && !waiting && seconds > 0 && html``} ${start !== undefined && html``} ${ @@ -1881,7 +1897,7 @@ function LiveLine({ live, approvals, start }) { live.busy && canSteer() && !COARSE.matches && - html`Esc stops` + html`${t("Esc stops")}` } `; } @@ -2060,7 +2076,7 @@ export function Transcript() { return html`

${missing}

- +
`; } @@ -2129,7 +2145,7 @@ export function Transcript() { sessionsLoaded && (source ? html`` : html`

<${Icon} name="fork" size=${14} /> Forked from another session @@ -2138,7 +2154,7 @@ export function Transcript() { ${ start > 0 ? html`

- +
` : first && (first.kind === "compaction" || first.kind === "reset") && @@ -2155,7 +2171,7 @@ export function Transcript() { html`
π

- ${conversation.kind === "subagent" ? "This subagent has no messages yet." : "Ask anything. Pi works in this session's folder, and keeps working if the server restarts."} + ${t(conversation.kind === "subagent" ? "This subagent has no messages yet." : "Ask anything. Pi works in this session's folder, and keeps working if the server restarts.")}

` } @@ -2183,7 +2199,7 @@ export function Transcript() { showJump && html` @@ -1124,7 +1139,7 @@ export const anchorStyle = (anchor) => /** The short name of a model for chips: "Claude Opus 5.5" stays, long ids lose their date suffix. */ export function modelLabel(agent) { if (!agent?.model) { - return "No model"; + return t("No model"); } return agent.modelName ?? agent.model.modelId.replace(/-\d{8}$/, ""); From 6f7efeafedd7315c3c67b136a7f2ba4fc85abcde Mon Sep 17 00:00:00 2001 From: Xianbei233 <16370242+Xianbei233@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:07:05 +0800 Subject: [PATCH 2/3] fix(i18n): leave drop-in summaries untouched --- web/sheets/extensions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sheets/extensions.js b/web/sheets/extensions.js index 43b23970..7899eb55 100644 --- a/web/sheets/extensions.js +++ b/web/sheets/extensions.js @@ -173,7 +173,7 @@ export function ExtensionsSheet() { ${module.source === "built-in" ? t(module.title) : module.title} ${module.error && html` · ${t("failed to load")}`} - ${module.summary && html`
${t(module.summary)}
`} + ${module.summary && html`
${module.source === "built-in" ? t(module.summary) : module.summary}
`} ${ note && html`
${note.text}
` From 162eb40456da8c324ae7a984dd15c71a82a12958 Mon Sep 17 00:00:00 2001 From: Pi Pocket Maintenance Date: Mon, 12 Oct 2026 18:16:29 +0800 Subject: [PATCH 3/3] feat(i18n): add optional UI locales and Chinese pack --- docs/architecture.md | 2 + docs/examples/zh-cn-ui.ts | 1277 ++++++++++++++++++++++++++++++++++++ docs/extensions.md | 6 + docs/map.md | 5 +- src/server/app.ts | 21 +- src/server/commands.ts | 27 +- src/server/host.ts | 1 + test/docs-examples.test.ts | 1 + test/i18n-notice.test.ts | 38 ++ test/i18n-ui.test.ts | 79 ++- test/zh-cn-ui.test.ts | 106 +++ web/app.js | 4 +- web/browser-panel.js | 14 +- web/chat.js | 10 +- web/commands.js | 20 +- web/composer.js | 87 +-- web/diff.js | 4 +- web/home.js | 35 +- web/launcher.js | 28 +- web/notify.js | 4 +- web/peeks.js | 8 +- web/pip.js | 61 +- web/rich.js | 2 +- web/sessions.js | 29 +- web/share.js | 2 +- web/sheets.js | 2 +- web/sheets/branch.js | 2 +- web/sheets/extensions.js | 4 +- web/sheets/folder.js | 4 +- web/sheets/hooks.js | 100 +-- web/sheets/memory.js | 61 +- web/sheets/menu.js | 76 ++- web/sheets/message.js | 12 +- web/sheets/model.js | 6 +- web/sheets/people.js | 6 +- web/sheets/pi-sessions.js | 55 +- web/sheets/running.js | 2 +- web/sheets/viewers.js | 2 +- web/store.js | 12 +- web/subagents-board.js | 84 +-- web/subagents.js | 37 +- web/transcript.js | 24 +- web/trust.js | 68 +- web/ui.js | 4 +- web/voice.js | 12 +- 45 files changed, 2041 insertions(+), 403 deletions(-) create mode 100644 docs/examples/zh-cn-ui.ts create mode 100644 test/i18n-notice.test.ts create mode 100644 test/zh-cn-ui.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index b34b46b5..445f90d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,8 @@ The harness, models, and settings exist only once `#open` has set them up, after Extensions never see `PocketApp`. They get `PocketHost` (`host.ts`), whose functions call into the app lazily. The HTTP layer parses a request, checks it, calls a part, and writes the answer; it holds no state of its own beyond invites and the event streams' pollers. +Enabled extensions may additionally register validated, data-only `uiLocales`; authenticated `hello` messages carry those packs to the browser. The client translates explicit UI strings, falling back to English for unknown keys. This does not translate user content or change server behavior. + ## PiP's conversation PiP (`pip.ts`) is a conversation with a `pip` mark in its session entry, which `sessions()` leaves out of every list. The harness settings select every installed extension but PiP's (`pocket-pip`) by default; PiP's conversation stores its own selection (`PIP_EXTENSIONS`, without bash, write, or edit), which `ensure` sets again each time it opens, so PiP's tools reach no other conversation. Its tools act through `Commands` and `Schedules` as PiP's person, with that person's checks, so `canSee` keeps every PiP from everyone but its person: anyone who could write to it would act as them. Its messages to sessions are once per tool call (a request id hashed from the call's key), and each follow leaves a mark in its list (`FollowsDoc`) when it reports or is stopped, so a replayed call neither sends nor follows twice. A tab's PiP panel is a second client in PiP's room (`setPip`), whose `view` events reach the tab as `pipview`; `watching` counts it, so PiP's answers push nothing while it shows. diff --git a/docs/examples/zh-cn-ui.ts b/docs/examples/zh-cn-ui.ts new file mode 100644 index 00000000..3934cf0d --- /dev/null +++ b/docs/examples/zh-cn-ui.ts @@ -0,0 +1,1277 @@ +/** Registers Simplified Chinese translations for fixed Pocket UI; user and model content stay unchanged. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +const strings: Record = { + "done · new": "已完成 · 有新内容", + "just left": "刚刚离开", + pinned: "已置顶", + Wrap: "自动换行", + Code: "代码", + "Compacting…": "正在压缩…", + "Compacted.": "已压缩。", + "in chat": "在成员聊天中", + "in the conversation": "在对话中", + "Enter puts it in the box · Esc closes": "Enter 填入输入框 · Esc 关闭", + "Nothing sent from this browser matches.": "此浏览器的发送历史中没有匹配项。", + "Pi sees the output (!! to keep it from Pi)": "Pi 可见输出(使用 !! 可不让 Pi 看到)", + "only the people here see the output": "仅此处成员可见输出", + "A large folder: only the files nearest its top are listed.": + "文件夹较大:仅列出最接近顶层的文件。", + context: "上下文", + "It goes into the message box, so you can add to it before you send.": + "内容会先放入输入框,你可以补充后再发送。", + "Ask PiP to watch a repository, such as “watch acme/api: when CI fails on main, fix it”. It needs no address.": + "可以请 PiP 监视仓库,例如“监视 acme/api:main 分支的 CI 失败时修复它”。无需提供 Webhook 地址。", + "For GitHub: the repository's Settings → Webhooks → Add webhook; paste the address as the Payload URL and the secret as the Secret, choose application/json, and pick the events.": + "GitHub 设置:打开仓库的 Settings → Webhooks → Add webhook;将地址填入 Payload URL,密钥填入 Secret,选择 application/json 和所需事件。", + "Context compacted": "上下文已压缩", + "Forked from another session": "从其他会话分叉而来", + "Also reachable at:": "也可通过以下地址访问:", + running: "运行中", + "System prompt": "系统提示", + "Done when": "完成条件", + "This session": "此会话", + All: "全部", + "runs in this session's folder": "在此会话的文件夹中运行", + "guard failed": "安全检查加载失败", + "guard on": "安全检查已开启", + "guard off": "安全检查已关闭", + "from Pi in the terminal": "来自终端中的 Pi", + "the model and thinking new sessions start with": "新会话使用的模型和思考级别", + "e.g. We fixed the login bug; next is the signup form.": + "例如:登录问题已修复,接下来处理注册表单。", + "Summarize older messages to free up context": "总结较早的消息,释放上下文空间", + "Start a new context: Pi starts fresh, history stays": "新建上下文:Pi 从头开始,历史记录保留", + "Set what Pi is told with every message here": "设置 Pi 在此会话每次收到消息时遵循的指令", + "Switch the model": "切换模型", + "Set the thinking level": "设置思考级别", + "Start a new session in this folder": "在此文件夹中开始新会话", + "Rename this session": "重命名此会话", + "Change the working directory": "更改工作目录", + "Plan mode on or off: Pi reads and proposes, and changes nothing": + "开启或关闭计划模式:Pi 只阅读和提议,不作更改", + "Send Pi a message later or on repeat: in 2h …, tomorrow 9:00 …, every weekday 8:00 …": + "稍后或定期向 Pi 发送消息:in 2h …、tomorrow 9:00 …、every weekday 8:00 …", + "Pi keeps going until a check passes, such as npm test (5 checks at most)": + "Pi 持续工作直到检查通过,例如 npm test(最多检查 5 次)", + "Copy Pi’s last reply": "复制 Pi 的最后一条回复", + "Find in this session’s messages": "搜索此会话中的消息", + "Show the model, context use, and cost": "显示模型、上下文用量与费用", + "Download this conversation as Markdown": "将此对话下载为 Markdown", + "Switch to another session": "切换到其他会话", + "Open the browser Pi uses, or an address in it, such as localhost:5173": + "打开 Pi 使用的浏览器,或在其中打开地址,例如 localhost:5173", + "Show or hide the Files tile: the folder's files, and what changed": + "显示或隐藏文件面板:文件夹中的文件与更改", + "Review the folder's uncommitted changes, file by file": "逐个文件审查文件夹中尚未提交的更改", + "Switch the folder to another git branch, or make one": + "将文件夹切换到其他 Git 分支,或新建分支", + "Show or hide peek tiles: other sessions' live work beside this one": + "显示或隐藏预览卡片:在旁边查看其他会话的实时进展", + "Show artifacts": "显示制品", + "Change the theme: an Omarchy theme, or desktop to follow yours": + "更改主题:选择 Omarchy 主题,或使用 desktop 跟随桌面", + Triggers: "触发器", + "Compacting PiP's context…": "正在压缩 PiP 上下文…", + "PiP's context compacted.": "PiP 上下文已压缩。", + "Nothing to compact: the context fits in the recent window.": + "无需压缩:上下文仍在近期消息窗口内。", + "Compaction aborted.": "压缩已中止。", + "Uncommitted changes in this session's folder": "此会话文件夹中尚未提交的更改", + "No branch: switch to one": "当前无分支:切换到一个分支", + "Remembered. Pi uses it once the owner approves it.": + "已记下。所有者批准后,Pi 才会使用这条经验。", + "Remembered. Pi gets it from its next message.": "已记下。Pi 将从下一条消息起使用这条经验。", + "Say which command has to pass, such as /until npm test.": + "请指定必须通过的命令,例如 /until npm test。", + Reports: "报告", + Queue: "排队", + "Asked ✓": "已申请 ✓", + "Done:": "已完成:", + "Stopped:": "已停止:", + Until: "直到", + passes: "通过", + "was merged.": "已合并。", + "was closed.": "已关闭。", + "had nothing new for two weeks: watch stopped.": "两周内没有新动态,已停止监视。", + "something went wrong": "出现错误", + Watching: "正在监视", + "owner's comments only": "仅接收仓库所有者的评论", + "last look failed": "上次检查失败", + waiting: "等待中", + "Only the repository owner's comments count: gh cannot push there, so GitHub does not say who else can write.": + "仅接收仓库所有者的评论:gh 无权向该仓库推送,因此 GitHub 无法告知其他哪些人具有写入权限。", + "Discard these reports": "丢弃这些报告", + "Discard these reports: Pi will not get them": "丢弃这些报告:Pi 将不会收到它们", + "not sent to Pi": "不会发送给 Pi", + "Send (Enter)": "发送(Enter)", + desktop: "桌面", + folded: "已折叠", + "PiP is not ready yet: your message waits in its box.": + "PiP 尚未就绪:你的消息仍保留在输入框中。", + "Compact it when it grows.": "上下文增多时可手动压缩。", + Stopped: "已停止", + "Hi. I’m PiP.": "你好,我是 PiP。", + "needs you": "待你处理", + "compacting…": "正在压缩…", + "working…": "正在工作…", + "PiP, your assistant": "PiP,你的助手", + "Compact: PiP keeps a summary of older messages, and reads less each time": + "压缩:PiP 保留较早消息的摘要,减少每次读取的内容", + "Start fresh: PiP forgets this conversation": "重新开始:PiP 不再记住此前的对话", + "Start fresh? PiP forgets this conversation; you can still read it.": + "重新开始?PiP 将不再记住此前的对话,但你仍可阅读历史记录。", + "Open as a conversation": "作为会话打开", + "Open as a conversation, with every step": "作为会话打开,查看每个步骤", + "Close (Esc)": "关闭(Esc)", + "PiP is working: open PiP": "PiP 正在工作:打开 PiP", + "PiP has something new: open PiP": "PiP 有新消息:打开 PiP", + "PiP (Alt A)": "PiP(Alt A)", + "PiP, your assistant (Alt A)": "PiP,你的助手(Alt A)", + "something new": "有新消息", + "New session (Alt N)": "新建会话(Alt N)", + "no posts yet": "尚未收到推送", + to: "发送至", + "Secret:": "密钥:", + "GitHub, signed": "GitHub,已签名", + "secret in the address": "密钥包含在地址中", + "from anyone": "接收任何人的内容", + "Address copied: GitHub's Payload URL.": "已复制地址:请填入 GitHub 的 Payload URL。", + "Address copied. Keep it secret: anyone with it can message Pi.": + "已复制地址。请妥善保密:持有此地址的任何人都能向 Pi 发送消息。", + "Secret copied: GitHub's Secret.": "已复制密钥:请填入 GitHub 的 Secret。", + "assigned to you": "已指派给你", + "nothing sent yet": "尚未发送任何消息", + "Paste the new one into GitHub's Secret for it.": "请将新密钥粘贴到 GitHub 对应的 Secret 中。", + "Give the new address to whatever posts to it.": "请将新地址提供给向此处推送的服务。", + "Other services (GitHub, a deploy, a monitor) post to a webhook's address, and each post becomes a message to Pi in a session, or to PiP: your instructions, then what came.": + "其他服务(GitHub、部署或监控服务)向 Webhook 地址推送内容,每次推送都会成为发送给会话中的 Pi 或 PiP 的消息:先是你的指令,再是收到的内容。", + Name: "名称", + Deploys: "部署通知", + "When a deploy fails, find out why and fix it. Ignore the ones that pass.": + "部署失败时,查明原因并修复;忽略成功的部署。", + "Posted by": "推送来源", + "Signed: GitHub gets the address and, separately, a secret; the address alone lets nothing in.": + "签名验证:分别向 GitHub 提供地址和密钥;仅有地址无法发送内容。", + "The secret is in the address: anyone who has it can message Pi as you.": + "密钥包含在地址中:持有地址的任何人都能以你的身份向 Pi 发送消息。", + "From anyone: issues, PRs, and comments by people outside the repository too": + "接收任何人的内容:包括仓库外部人员的议题、PR 和评论", + "Strangers' words reach Pi too, fenced as data.": + "陌生人的内容也会传给 Pi,并明确标记为外部数据。", + "Off: posts about issues, PRs, and comments by people who cannot write to the repository are dropped (a 202 says so), and their words in other posts are left out. GitHub is asked who can write: gh must be able to push there.": + "关闭时:无仓库写入权限者的议题、PR 和评论推送会被丢弃(返回 202),其他推送中引用的这些内容也会被移除。系统会向 GitHub 查询写入权限,因此 gh 必须具有该仓库的推送权限。", + "Sends to": "发送到", + "PiP decides what to do: start a session, message one, or tell you.": + "由 PiP 决定如何处理:创建会话、向会话发送消息或通知你。", + "everywhere: every session, and PiP": "所有项目:每个会话及 PiP", + "this project": "本项目", + "Approved: Pi goes by it from its next message.": "已批准:Pi 将从下一条消息起遵循这条经验。", + "Saved. Pi uses it once the owner approves it.": "已保存。所有者批准后,Pi 才会使用这条经验。", + "Saved. Pi gets it from its next message.": "已保存。Pi 将从下一条消息起使用这条经验。", + "Short lessons Pi keeps across sessions: how to build and test, gotchas, conventions, preferences.": + "Pi 跨会话保留的简短经验:构建与测试方法、易错事项、约定和偏好。", + "PiP keeps lessons for everywhere.": "PiP 保留适用于所有项目的经验。", + "Pi proposes them as it learns, and the owner approves; yours count at once.": + "Pi 学到经验后会提出建议,由所有者批准;你亲自添加的经验立即生效。", + "e.g. Run the tests with npm test; the browser tests need Chromium.": + "例如:使用 npm test 运行测试;浏览器测试需要 Chromium。", + For: "适用范围", + "Nothing yet.": "暂无内容。", + "A new lesson": "一条新经验", + "Forgetting this lesson": "忘记这条经验", + "New text for this lesson": "这条经验的新内容", + "the owner": "所有者", + "Discarded.": "已丢弃。", + "not decided": "尚未决定", + trusted: "已信任", + "not trusted": "不信任", + images: "支持图像", + "the last model picked": "上次选择的模型", + "Pi went on": "Pi 在终端中有新进展", + "in Pocket": "已在 Pocket 中继续", + "Continue a Pi session": "继续终端中的 Pi 会话", + "Sessions of Pi in the terminal on this computer. One continues here as a new session; its file stays as it is.": + "此设备上终端中的 Pi 会话。可在这里创建新会话继续,原会话文件保持不变。", + "Finding Pi's sessions": "正在查找 Pi 会话", + "Pi has no sessions on this computer yet.": "此设备上尚无 Pi 会话。", + "Search Pi's sessions: titles, folders, and messages": "搜索 Pi 会话的标题、文件夹和消息", + "Continued from Pi. Pi has the conversation as Pi left it; nothing it did runs again.": + "已从 Pi 会话继续。Pi 会接收此前保留的上下文,不会重新执行之前的操作。", + "Pi session": "Pi 会话", + "Reading the session": "正在读取会话", + Folder: "文件夹", + "not signed in here": "此处尚未登录", + Messages: "消息", + "The folder it worked in is not there anymore, so it cannot continue here.": + "原工作文件夹已不存在,因此无法在此继续。", + "It continues with Pi Pocket's usual model.": "将使用 Pi Pocket 的默认模型继续。", + "Continued here before Pi went on in the terminal.": + "此前已在这里继续过,此后 Pi 又在终端中取得了进展。", + "Already continued here.": "已在此处继续过。", + "Open it": "打开", + "Its last messages": "最近的消息", + "Continue it here again": "再次在此继续", + "Continue in Pocket": "在 Pocket 中继续", + "Pi gets the conversation as Pi left it, with Pi Pocket's instructions and tools. Nothing Pi did runs again, and the session's file stays as it is.": + "Pi 会接收此前保留的对话上下文,并使用 Pi Pocket 的指令和工具。先前操作不会重新执行,原会话文件保持不变。", + approve: "待批准", + "need approval": "待批准", + "just now": "刚刚", + "Needs you": "待你处理", + "needs approval": "待批准", + failed: "失败", + Session: "会话", + Ended: "已结束", + Asked: "任务", + Now: "当前", + Error: "错误", + "report on its way to Pi": "报告正在投递给 Pi", + "Review the approval": "查看审批请求", + "Hover a square to see what it is doing": "将指针移到方块上,查看它正在做什么", + "←→↑↓ move · Enter opens": "←→↑↓ 移动 · Enter 打开", + "Find a subagent or task": "查找子代理或任务", + "Loading subagents…": "正在加载子代理…", + "No subagents yet. Pi starts them when a task splits into parts that can run at once.": + "暂无子代理。任务可拆分为并行部分时,Pi 会启动子代理。", + "Back to the conversation (Esc)": "返回对话(Esc)", + "Show only": "仅显示", + "Working…": "正在工作…", + "Raise it": "提高限额", + "They go once the owner raises it.": "所有者提高限额后,报告才会发送。", + "Put the subagents bar away": "收起子代理栏", + "Put away": "收起", + "the subagent": "子代理", + "Not trusted: the project's own skills stay off. /trust changes that.": + "未信任:项目自带的技能保持关闭。可使用 /trust 更改。", + "Trust this project?": "信任此项目?", + "Not now": "暂不处理", + "Not now: ask again in a new tab": "暂不处理:在新标签页中再次询问", + "Pi has them.": "Pi 已加载这些技能。", + "They load once you trust it.": "信任此项目后才会加载这些技能。", + "They stay off while it is not trusted.": "不信任此项目时,这些技能保持关闭。", + "They load unless you choose Don't trust.": "除非选择“不信任”,否则会加载这些技能。", + "It has no skills of its own, so this changes nothing in Pi Pocket here.": + "此项目没有自带技能,因此这一选择不会改变此处 Pi Pocket 的行为。", + "Pi's trust store (~/.pi/agent/trust.json) cannot be read, so no project is trusted, and no answer can be saved until it is mended or deleted.": + "无法读取 Pi 的信任记录(~/.pi/agent/trust.json),因此所有项目均不受信任;修复或删除该文件后才能保存选择。", + "Trusted: no decision is saved for it, and Pi's defaultProjectTrust setting trusts every project.": + "已信任:尚未保存对此项目的选择,Pi 的 defaultProjectTrust 设置为信任所有项目。", + "Not trusted: no decision is saved for it, and Pi's defaultProjectTrust setting trusts no project.": + "不信任:尚未保存对此项目的选择,Pi 的 defaultProjectTrust 设置为不信任任何项目。", + "No decision yet. Until there is one, Pi Pocket leaves its skills in .agents/skills off.": + "尚未决定。在你作出选择前,Pi Pocket 不会加载此项目 .agents/skills 中的技能。", + "No decision yet.": "尚未决定。", + Trusted: "已信任", + "Not trusted": "不信任", + "Pi's CLI goes by the same decision, saved in ~/.pi/agent/trust.json. There, trusting a project also loads its .pi settings, extensions, and packages, which run code on this machine. Trust only folders whose contents you trust.": + "Pi 命令行使用同一份选择,保存在 ~/.pi/agent/trust.json 中。在命令行中信任项目还会加载其 .pi 设置、扩展和包,这些内容可在设备上执行代码。请只信任内容可靠的文件夹。", + "Only the owner can change this.": "只有所有者可以更改此项。", + "This image could not be loaded": "无法加载此图片", + "I keep an eye on your sessions and repositories: ask what’s going on, start new work, have me check back later, or watch a repository and act when something happens.": + "我会关注你的会话和仓库:你可以询问进展、开始新工作、让我稍后跟进,或监视仓库并在出现新情况时采取行动。", + "What needs me?": "有哪些事情需要我处理?", + "What happened today?": "今天有什么进展?", + "Start a session to …": "新建会话来…", + "Every weekday at 9:00, tell me what changed overnight": + "每个工作日 9:00,告诉我昨晚有什么变化", + "When CI fails on main in …": "当以下仓库 main 分支的 CI 失败时…", + "Needs approval": "待批准", + stopped: "已停止", + done: "已完成", + "checks running": "检查进行中", + "checks pass": "检查通过", + "checks failed": "检查失败", + "Keep a lesson for Pi in every session of this project": "为 Pi 保存适用于此项目所有会话的经验", + "Open PiP, your assistant over every session, or ask it something": + "打开跨会话助手 PiP,或向它提问", + "Whether Pi trusts this project, for its own skills": "管理 Pi 对此项目自带技能的信任", + "Watch this session's pull request: Pi fixes failed checks and answers reviews until it is merged": + "监视此会话的 PR:Pi 会修复失败检查并回应评审,直到 PR 合并", + low: "低", + high: "高", + "Ask PiP…": "向 PiP 提问…", + "Tell PiP more…": "继续告诉 PiP…", + "Close PiP": "关闭 PiP", + Report: "报告", + "Fresh start": "已重新开始", + "Earlier messages summarized": "较早的消息已总结", + "PiP is not on here.": "此处尚未启用 PiP。", + "PiP is not ready yet. Try again in a moment.": "PiP 尚未就绪,请稍后重试。", + Memory: "记忆", + "This project": "本项目", + Everywhere: "所有项目", + Discard: "丢弃", + Forget: "忘记", + "Forget it": "确认忘记", + "Webhooks and watches": "Webhook 与监视", + "Copy address": "复制地址", + "New secret": "生成新密钥", + "Make webhook": "创建 Webhook", + "From anyone, not only the repository's people": "接收任何人的内容,不仅限于仓库成员", + "What Pi should do with each post": "Pi 应如何处理每次推送", + "Listening: tap to stop (Alt V)": "正在聆听:点击停止(Alt V)", + "Dictate (Alt V)": "听写(Alt V)", + "The microphone is blocked. Allow it for this site in the browser's settings.": + "麦克风已被阻止。请在浏览器设置中允许此网站使用麦克风。", + "This browser does not allow dictation here.": "此浏览器不允许在这里使用听写。", + "No speech heard. Tap the mic and try again.": "未听到语音。请点击麦克风重试。", + "No microphone found.": "未找到麦克风。", + "Dictation needs the network: the browser recognises speech on its own servers.": + "听写需要联网:浏览器会通过其服务器识别语音。", + "This browser cannot recognise your language.": "此浏览器无法识别你的语言。", + "Compact PiP's context": "压缩 PiP 上下文", + "Above 40k tokens": "超过 4 万 token", + "Above 60k tokens": "超过 6 万 token", + "Above 100k tokens": "超过 10 万 token", + "As sessions do": "与会话保持一致", + "Once PiP is idle above this, older messages become a short summary.": + "PiP 空闲时,若上下文超过此阈值,较早的消息会被压缩为简短摘要。", + "Open or close PiP, your assistant": "打开或关闭你的助手 PiP", + "Dictate into the message box, or stop (where the browser can)": + "在消息框中开始或停止听写(需浏览器支持)", + "Watch pull request": "监视拉取请求", + "fix CI, answer reviews": "修复 CI 并回复评审", + "GitHub, other services": "GitHub 及其他服务", + "Project trust": "项目信任", + "Remember…": "记住…", + "keep it as a lesson for Pi": "保存为 Pi 的经验", + "Ask PiP": "向 PiP 提问", + "your assistant, over any screen": "可在任何页面使用的助手", + "can steer": "可操作", + "k: in Changes, the next or previous file": "k:在更改审阅中切换到下一个或上一个文件", + "p: in Changes, the next or previous change": "p:在更改审阅中切换到下一个或上一个改动", + Places: "快捷入口", + Files: "文件", + Changes: "更改", + Browser: "浏览器", + Artifacts: "成果", + Chat: "聊天", + Find: "查找", + Branch: "分支", + Peeks: "会话速览", + Open: "打开", + Menu: "菜单", + "Session menu": "会话菜单", + "rename, context, settings": "重命名、上下文、设置", + "Turn off take turns": "关闭轮流操作", + "Take turns": "轮流操作", + "anyone here can send to Pi again": "现在任何人都可以向 Pi 发送消息", + "one person drives Pi at a time": "同一时间只能由一人操作 Pi", + Rename: "重命名", + "Unpin from the top": "取消置顶", + "Pin to the top": "置顶", + "this browser": "此浏览器", + Worktree: "工作树", + "Working directory": "工作目录", + "Close the browser": "关闭浏览器", + "Instructions for Pi": "给 Pi 的指示", + none: "无", + "Compact context": "压缩上下文", + "summarize older messages": "总结较早的消息", + "New context": "新上下文", + "Pi starts fresh; history stays": "Pi 将从新上下文开始,历史记录会保留", + "Scheduled messages": "定时消息", + "Copy link": "复制链接", + "Link copied. Other signed-in devices can open it.": "链接已复制。其他已登录设备可以打开。", + "Export as Markdown": "导出为 Markdown", + "the whole history": "完整会话记录", + Unarchive: "取消归档", + Archive: "归档", + Subagents: "子代理", + working: "工作中", + idle: "空闲", + App: "应用", + Appearance: "外观", + "Your name": "你的姓名", + People: "成员", + "you can view": "仅可查看", + "Sign in another device": "在另一台设备登录", + Notifications: "通知", + "Pi finished, approvals, chat": "Pi 完成任务、审批请求和聊天消息", + "Running now": "正在运行", + "everything Pi is doing": "Pi 当前执行的所有任务", + Spend: "用量", + "by person and session, limits": "按人员和会话统计及限额", + yours: "你的用量", + Providers: "模型提供方", + Extensions: "扩展", + "Lancet Guard on": "Lancet Guard 已开启", + "Lancet Guard off": "Lancet Guard 已关闭", + "Restart server": "重启服务器", + "running work resumes": "当前任务将在重启后继续", + "Sign out": "退出登录", + "Close the files": "关闭文件面板", + "Open the browser": "打开浏览器", + "Open or close the browser": "打开或关闭浏览器", + "Open the menu": "打开菜单", + "Places: Files, Changes, Browser, and more": "快捷入口:文件、更改、浏览器等", + "Places (or swipe up here)": "快捷入口(或在此处向上滑动)", + "Open the people chat": "打开成员聊天", + "People and invites": "成员与邀请", + "Model providers": "模型提供方", + Devices: "设备", + "Theme, tiling, motion": "主题、平铺布局与动效", + "Open or close the Files tile: the folder's files, and what changed": + "打开或关闭文件面板:查看文件及其更改", + Theme: "主题", + "Layout and motion": "布局与动效", + "Tiled windows": "平铺窗口", + "Wide screens: the sidebar and the session as Hyprland windows, with gaps and the active border": + "宽屏下,侧栏和会话采用 Hyprland 窗口布局,并显示窗口间隙和活动边框", + "Peek tiles": "会话速览", + "Other sessions beside this one, live while on screen: working, waiting for you, done since you looked, and pinned. Also the tiles button in the top bar, or Alt+P": + "在旁边实时查看其他会话:正在工作、等待你处理、上次查看后已完成或已置顶的会话。也可使用顶栏按钮或 Alt+P", + "Desktop wallpaper": "桌面壁纸", + "Your wallpaper in the gaps between windows, while following the desktop": + "跟随桌面主题时,在窗口间隙显示桌面壁纸", + Sidebar: "侧栏", + "Folded, it shows numbered sessions like workspaces": "折叠后会像工作区一样显示编号会话", + Folded: "折叠", + Motion: "动效", + "Auto follows your system's reduced-motion setting": "自动跟随系统的减少动态效果设置", + Auto: "自动", + Full: "完整", + Reduced: "减少", + "Text size": "字号", + "Keyboard shortcuts": "键盘快捷键", + "Follow the Omarchy theme of the machine Pi Pocket runs on": + "跟随运行 Pi Pocket 的设备上的 Omarchy 主题", + light: "浅色", + Running: "运行中", + Today: "今天", + Yesterday: "昨天", + now: "刚刚", + React: "回应", + Discuss: "讨论", + More: "更多", + Plan: "计划", + "Attach files": "添加文件", + "To the bottom": "前往底部", + Withdraw: "撤回", + "Stop quoting": "停止引用", + "Mark as seen": "标记为已读", + "Close the People panel": "关闭成员面板", + "Sent before": "之前发送的内容", + Close: "关闭", + Cancel: "取消", + Save: "保存", + "Saving…": "正在保存…", + Copy: "复制", + Copied: "已复制", + "Copied.": "已复制。", + "Code copied.": "代码已复制。", + "Could not copy.": "复制失败。", + Send: "发送", + "Back to sessions": "返回会话列表", + Stop: "停止", + Back: "返回", + Forward: "前进", + Clear: "清除", + Done: "完成", + "Open tab": "在新标签页打开", + "Open it again": "重新打开", + "Try again": "重试", + Reload: "重新加载", + Refresh: "刷新", + Loading: "加载中", + "Loading…": "正在加载…", + "Loading sessions": "正在加载会话", + "Loading extensions": "正在加载扩展", + "Loading providers": "正在加载模型提供方", + "Loading notes": "正在加载笔记", + Working: "处理中", + "Starting the browser": "正在启动浏览器", + Looking: "正在查找", + "Looking for servers": "正在查找服务器", + "No model": "未选择模型", + Model: "模型", + Models: "模型列表", + "Search models": "搜索模型", + "Search providers": "搜索模型提供方", + Thinking: "思考模式", + Sessions: "会话", + "All sessions": "全部会话", + Recent: "最近", + Folders: "文件夹", + Archived: "已归档", + Pinned: "已置顶", + Matches: "匹配项", + "Search sessions": "搜索会话", + "No archived sessions.": "没有已归档的会话。", + "No matches.": "没有匹配项。", + "No sessions yet. Start one with New.": "还没有会话。点击“新建”开始。", + New: "新建", + "New session": "新会话", + "New folder": "新建文件夹", + "Recent sessions": "最近的会话", + "Jump back in": "继续上次会话", + Launcher: "启动器", + "Open the launcher": "打开启动器", + "Fold the sidebar": "折叠侧栏", + "Unfold the sidebar": "展开侧栏", + "Clear the selection": "清除选择", + "Clear the selection (Esc)": "清除选择(Esc)", + "Selected sessions": "已选会话", + Pin: "置顶", + Unpin: "取消置顶", + owner: "所有者", + guest: "访客", + "view only": "只读", + "View only": "只读", + "Ask to drive": "申请操作权限", + "Take the wheel": "接管操作", + "Hand over": "交出操作权", + "Hand over…": "交出操作权…", + "Let go": "放弃操作权", + "You're driving": "你正在操作", + "No one is driving": "当前无人操作", + "Can read along, react, and chat, but not steer Pi": "可以查看、回应和聊天,但不能操作 Pi", + "Can steer Pi, which can run commands on this machine": "可以操作 Pi,包括在此设备上运行命令", + "Take turns is on: the driver uses the page.": "轮流操作已开启:仅当前操作人可以使用此页面。", + "You can watch; people who can steer use the page.": + "你可以查看;有操作权限的成员可以使用此页面。", + "Handoff note (optional)": "交接备注(可选)", + "Start a new context": "开始新上下文", + "Pi starts fresh: it no longer sees the messages so far, though everyone here still does.": + "Pi 将从新上下文开始,不再读取此前的消息;但此处所有人仍能查看历史记录。", + Instructions: "指示", + "Shown on your messages to others": "显示在你发给其他人的消息中", + "Pi gets these with every message in this session, after its own instructions. Everyone here can see them. Leave empty for none.": + "本会话中的每条消息都会附带这些指示,排在 Pi 自身指示之后。所有参与者都能看到。留空表示不设置。", + "e.g. Use pnpm, not npm. Ask before adding dependencies.": + "例如:使用 pnpm 而不是 npm;添加依赖前先询问。", + "What should the summary keep? (optional)": "摘要需要保留什么?(可选)", + "e.g. the failing test names": "例如:失败的测试名称", + Compact: "压缩", + "Session title": "会话名称", + "Invite someone": "邀请成员", + "Invite code": "邀请码", + "New invite": "创建邀请", + "No invite yet. Tap New invite to try again.": "尚无邀请。点击“创建邀请”重试。", + "They can": "对方权限", + "Expires after": "有效期", + "Every session": "所有会话", + "Only this session": "仅此会话", + Steer: "操作", + "Restart Pi Pocket to sign in another device as the owner.": + "请重启 Pi Pocket,才能在其他设备上以所有者身份登录。", + "Sign in as me (owner)": "以我的身份(所有者)登录", + Notes: "笔记", + "One page of notes for everyone here: the plan, decisions, who does what. Pi does not see it unless you send it.": + "所有参与者共用的一页笔记:记录计划、决定和分工。除非发送给 Pi,否则 Pi 看不到这些内容。", + "No messages yet. Pi does not see this chat.": "还没有消息。Pi 看不到这里的聊天内容。", + Discussing: "正在讨论", + "Keep mine": "保留我的版本", + "Chat with people here": "与此处成员聊天", + "Message the people here…": "给这里的成员发消息…", + "Message the people here… @ to mention": "给这里的成员发消息…输入 @ 提及成员", + "Extensions give Pi its tools and checks in Pi Pocket. A change applies to every session right away and stays after restarts.": + "扩展为 Pi Pocket 中的 Pi 提供工具和检查功能。更改会立即应用到所有会话,并在重启后保留。", + "Drop-ins stay off until the owner turns them on, and run inside the server with the owner's rights.": + "自定义扩展默认关闭,由所有者启用后才会运行;它们在服务器中以所有者的权限运行。", + "Approvals need someone else": "审批需要其他人确认", + "Off in Pi Pocket: bash, write, and edit calls run unchecked here. Pi itself still uses it.": + "在 Pi Pocket 中已关闭:此处的 bash、write 和 edit 调用不会经过检查。Pi 自身仍会使用其设置。", + "Checks bash, write, and edit calls. Risky ones wait for someone in the session to allow them.": + "检查 bash、write 和 edit 调用。风险较高的操作需要等待会话中的成员批准。", + "Only the owner can change them.": "只有所有者可以修改。", + "Turn off": "关闭", + "Restart the server (menu → Restart server) to manage extensions from here.": + "请从菜单中选择“重启服务器”,之后即可在此管理扩展。", + "Share to Pi": "分享给 Pi", + "You can view sessions here but not send to Pi.": "你可以查看这些会话,但不能向 Pi 发送消息。", + "Sign in": "登录", + "No artifacts yet. Ask Pi to build something you can look at: a chart, a demo, a game.": + "还没有成果。可以让 Pi 创建图表、演示或小游戏等可视化内容。", + "No file matches.": "没有匹配的文件。", + "Nothing is scheduled.": "没有定时消息。", + "Nothing is running.": "当前没有正在运行的任务。", + "No folders here.": "此处没有文件夹。", + "An empty folder.": "空文件夹。", + "A binary file: nothing to show as text.": "二进制文件:无法以文本形式显示。", + "Not a regular file (a pipe or a device, say): nothing to show.": + "这不是普通文件(例如管道或设备),无法显示内容。", + "Pick a file to read it here.": "选择一个文件以在此处查看。", + "Go to file": "跳转到文件", + "Filter files": "筛选文件", + "Listing the folder…": "正在列出文件夹…", + "Finding files…": "正在查找文件…", + "Opening the folder": "正在打开文件夹", + "Open the file": "打开文件", + "Close the file": "关闭文件", + "Fold every folder": "折叠所有文件夹", + "Has changes": "有更改", + "Back to Changes": "返回“更改”", + "Back to the files": "返回文件列表", + "Back to the conversation": "返回会话", + "Files and changes (Alt+E)": "文件与更改(Alt+E)", + "Only the files Pi edited": "仅显示 Pi 修改的文件", + "Changed files": "已更改的文件", + Viewed: "已查看", + "Mark as viewed (v)": "标记为已查看(v)", + "Pi edited it": "由 Pi 修改", + "Pi wrote or edited": "Pi 编写或修改的文件", + "Pi also edited (no uncommitted change)": "Pi 也修改过(当前没有未提交的更改)", + "Not a git repository: only Pi's edits are listed.": + "当前目录不是 Git 仓库:仅列出 Pi 的编辑。", + "Reading the file…": "正在读取文件…", + "Reading the diff": "正在读取差异", + "Asking git": "正在查询 Git", + "Show it": "展开", + "Show more": "显示更多", + "Show all": "显示全部", + "Show less": "收起", + "Show earlier messages": "显示更早的消息", + "Load everything": "加载全部内容", + "The newest 100 are listed.": "这里只显示最新的 100 条。", + "Show more below the hunk above": "显示上方差异块之后的更多内容", + "Show more above the hunk below": "显示下方差异块之前的更多内容", + Fold: "折叠", + Unfold: "展开", + Split: "并排", + Unified: "统一", + "Wrap long lines (w)": "折行显示长行(w)", + "Look again": "重新检查", + "No difference in text.": "文本没有变化。", + "Only its mode changed.": "仅文件模式发生变化。", + "Renamed, with no change to its text.": "已重命名,文本未变化。", + "A binary file: no lines to compare.": "二进制文件:没有可比较的文本行。", + "The diff is too long to show whole: the rest is left out.": "差异内容过长,未显示剩余部分。", + "This file is too big to show its other lines here.": "此文件过大,无法在此显示其余行。", + "The file changed after this diff was read, so its other lines cannot be shown. Refresh to see them.": + "读取差异后文件已更改,无法显示其余行。刷新以查看最新内容。", + "Undo changes": "撤销更改", + "Tap again to delete this new file": "再次点击即可删除这个新文件", + "Tap again to throw these changes away": "再次点击即可放弃这些更改", + "Delete this new file": "删除这个新文件", + "Undo the changes to this file": "撤销对此文件的更改", + "Delete?": "删除?", + "Undo?": "撤销?", + "It changed after you marked it viewed": "标记为已查看后,文件又发生了更改", + changed: "已更改", + "on branch": "位于分支", + show: "显示", + "Switch or make a branch": "切换或新建分支", + Branches: "分支列表", + "Switch to or make a branch": "切换到或创建分支", + "No branch: a commit": "无分支:当前位于某个提交", + "Checked out": "当前检出", + Remote: "远程分支", + Browse: "浏览", + Hide: "隐藏", + Use: "使用", + Create: "创建", + "Create and use": "创建并使用", + "No models are available. Add a provider first.": "暂无可用模型,请先添加模型提供方。", + "Sign in to a model provider": "登录模型提供方", + Context: "上下文", + "Plan mode": "计划模式", + "Plan mode.": "计划模式。", + "Pi reads and proposes; nothing changes until you approve.": + "Pi 会阅读并提出方案;获得批准前不会修改任何内容。", + "This subagent follows its session: it reads, and changes nothing.": + "此子代理遵循其会话设置:只读取,不做修改。", + "Approve plan": "批准计划", + "Pi is working…": "Pi 正在工作…", + "Thinking…": "思考中…", + "Stopped.": "已停止。", + "Waiting for someone who can steer to answer.": "正在等待有操作权限的成员回复。", + "The model request failed.": "模型请求失败。", + "Pi was told.": "已通知 Pi。", + Thought: "思考过程", + "Thought (redacted)": "思考过程(已隐藏)", + Allowed: "已允许", + Denied: "已拒绝", + Deny: "拒绝", + Allow: "允许", + "Open it in the browser": "在浏览器中打开", + "Watch in the browser": "在浏览器中查看", + Console: "控制台", + "Close the console": "关闭控制台", + "Address, such as localhost:5173": "地址,例如 localhost:5173", + "Type into the page…": "在页面中输入…", + "Type into the page": "在页面中输入", + "Fit the panel": "适应面板", + "Desktop 1280×800": "桌面 1280×800", + "Phone 390×844": "手机 390×844", + "Tablet 820×1180": "平板 820×1180", + Backspace: "退格键", + Enter: "回车键", + Tab: "Tab 键", + "Stop loading": "停止加载", + "Close the browser (Alt+B)": "关闭浏览器(Alt+B)", + "Close (Alt+E)": "关闭(Alt+E)", + "Close the keyboard bar": "关闭键盘栏", + "No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set PI_POCKET_BROWSER to a browser's path), then restart Pi Pocket.": + "运行 Pi Pocket 的设备上未找到浏览器。请安装 Chromium 或 Google Chrome(或将 PI_POCKET_BROWSER 设置为浏览器路径),然后重启 Pi Pocket。", + "The page closed.": "页面已关闭。", + "Starting the browser…": "正在启动浏览器…", + "Running on this machine": "在此设备上运行", + "Nothing is open yet. Pi, or someone who can steer, opens pages here.": + "当前没有打开的页面。Pi 或有操作权限的成员可以在此打开网页。", + "Find in session": "在会话中查找", + "Find in this session": "在此会话中查找", + "Words in a message, a command, a file…": "搜索消息、命令或文件…", + "Searching earlier history too…": "也在搜索更早的历史记录…", + "Launcher: sessions, actions, themes": "启动器:会话、操作和主题", + "… Alt 9: jump to a session, like a workspace": "… Alt 9:跳转到对应会话,类似工作区", + "In Changes: mark the file viewed, or not": "在“更改”中:标记文件为已查看或未查看", + "In Changes: the layout, auto, split, or unified": "在“更改”中:切换自动、并排或统一布局", + "In Changes: wrap long lines, or not": "在“更改”中:切换长行折行", + "Show or hide peek tiles": "显示或隐藏会话速览", + "Fold or unfold the sidebar": "折叠或展开侧栏", + "In the sidebar: select a session; drag to select every one you pass": + "在侧栏中:选择会话;拖动可连续选择经过的会话", + "In the sidebar: select a run of sessions": "在侧栏中:选择一组连续会话", + "Hold to see session numbers": "按住以查看会话编号", + "In the message box: commands": "在消息框中:查看命令", + "In the message box: mention a file or folder": "在消息框中:提及文件或文件夹", + "In the message box: run a command (!! keeps it from Pi)": + "在消息框中:运行命令(!! 表示不让 Pi 看到)", + "In an empty message box: what you sent before": "在空消息框中:查看之前发送的内容", + "In the message box: search what you sent before": "在消息框中:搜索之前发送的内容", + "Stop Pi while it works": "Pi 工作时停止它", + "Close what is open": "关闭当前打开的内容", + Actions: "操作", + Themes: "主题", + "Sessions, actions, themes…": "搜索会话、操作和主题…", + "Switch model": "切换模型", + "Switch branch": "切换分支", + "Rename session": "重命名会话", + "Copy link to this session": "复制此会话链接", + "Pi Pocket": "Pi Pocket", + "Stop the current run": "停止当前运行", + "Steer the current run…": "引导当前运行…", + "Listening…": "正在聆听…", + "Ask Pi anything…": "向 Pi 提问…", + "Queue a message for after this run…": "排队发送消息,待本次运行结束后处理…", + "Let Pi read them": "让 Pi 读取这些文件", + "Send them along": "一并发送", + Read: "读取", + Write: "写入", + Edit: "编辑", + Artifact: "成果", + Codemode: "代码模式", + Image: "图片", + "Copied Pi’s last reply.": "已复制 Pi 最近的回复。", + "Pi has not replied yet.": "Pi 还没有回复。", + "Press Esc again to stop Pi.": "再次按 Esc 停止 Pi。", + "Opened the new session. The original is unchanged.": "已打开新会话,原会话保持不变。", + "Restarting. Running work continues after the restart.": "正在重启。重启后当前任务会继续运行。", + "Nothing yet. Logs, errors, and failed loads show here.": + "暂无内容。日志、错误和加载失败信息会显示在这里。", + "Nothing pinned yet. Pin a reply of Pi's or a chat message to keep it at hand for everyone here.": + "还没有置顶内容。置顶 Pi 的回复或聊天消息,方便所有参与者查看。", + "This session works in its folder, not in a worktree.": + "此会话直接使用其文件夹,而不是独立工作树。", + "Remove anyway": "仍要移除", + "Remove the worktree": "移除工作树", + "No limit": "不限额", + "This browser has no room to keep the paste after a reload.": + "此浏览器没有足够空间在重新加载后保留剪贴板内容。", + "Ask anything. Pi works in this session's folder, and keeps working if the server restarts.": + "随时提问。Pi 会在本会话的文件夹中工作,即使服务器重启也会继续。", + "Upload failed: ": "上传失败:", + "Diffs: ": "差异显示:", + "Could not open it: ": "无法打开:", + "What was shared did not arrive. Share it again.": "分享内容未能送达,请重新分享。", + "What was shared could not be read. Share it again.": "无法读取分享内容,请重新分享。", + "Test sent. It shows as a system notification.": "测试通知已发送。请查看系统通知。", + "Notifications are on.": "通知已开启。", + "Notifications are off.": "通知已关闭。", + "Notifications are on for this device.": "此设备的通知已开启。", + "Notifications on this device": "在此设备上启用通知", + "Notify me about": "通知我以下内容", + "Pi finished": "Pi 已完成", + Approvals: "审批请求", + Mentions: "提及我的消息", + "Chat messages": "聊天消息", + "Send a test notification": "发送测试通知", + "Notifications need a secure (https) address. Open Pi Pocket through the Cloudflare Tunnel, or through tailscale serve, on this device.": + "通知需要安全的 HTTPS 地址。请在此设备上通过 Cloudflare Tunnel 或 tailscale serve 访问 Pi Pocket。", + "This browser cannot receive notifications. On iPhone and iPad, add Pi Pocket to the home screen first, then open it from there.": + "此浏览器无法接收通知。在 iPhone 或 iPad 上,请先将 Pi Pocket 添加到主屏幕,再从主屏幕打开。", + "Notifications are blocked for this site. Allow them in the browser's site settings, then come back.": + "此网站的通知已被阻止。请在浏览器的网站设置中允许通知,然后返回此处。", + "The service worker did not start.": "Service Worker 未能启动。", + "This device": "此设备", + "This message is not here anymore.": "此消息已不存在。", + "Send again in a new session": "在新会话中重新发送", + "With another model…": "使用其他模型…", + "Edit and send again…": "编辑并重新发送…", + "Edit and send again": "编辑并重新发送", + "Fork from here": "从此处分叉", + "everything up to this reply": "截至此回复的所有内容", + "Copy text": "复制文本", + "Stop its scripts": "停止脚本", + Run: "运行", + Source: "源代码", + Preview: "预览", + "Not saved yet": "尚未保存", + current: "当前", + "Drag to resize": "拖动以调整大小", + "The page": "页面", + "SVG image": "SVG 图片", + "Subagents working": "子代理正在工作", + "waiting for the server": "正在等待服务器", + starting: "正在启动", + "This week": "本周", + "This month": "本月", + Earlier: "更早", + Selected: "已选中", + "Waiting for approval": "等待批准", + "New chat messages": "有新聊天消息", + theme: "主题", + layout: "布局", + sessions: "会话", + models: "模型", + folder: "文件夹", + guard: "防护", + you: "你", + "tiled windows": "平铺窗口", + flat: "普通布局", + "failed to load": "加载失败", + off: "已关闭", + Drop: "放弃", + Dismiss: "关闭", + Note: "备注", + "Pasted text goes out in full where its placeholder is.": + "发送时会在占位符处还原完整粘贴内容。", + "connecting…": "正在连接…", + "reconnecting…": "正在重新连接…", + "The git branch": "Git 分支", + "Link copied.": "链接已复制。", + archived: "已归档", + "theme, tiling, motion, text size": "主题、平铺、动效、文字大小", + "Show peek tiles": "显示会话速览", + "Hide peek tiles": "隐藏会话速览", + Failed: "失败", + home: "首页", + "Turn tiling on": "开启平铺", + "Turn tiling off": "关闭平铺", + "gaps and window borders": "间距和窗口边框", + "other sessions' live work beside this one": "在此会话旁显示其他会话的实时进度", + "see and test pages with Pi": "与 Pi 一起查看和测试网页", + "browse the folder, read files": "浏览文件夹并读取文件", + "review what changed, file by file": "逐个文件检查更改", + "Turn plan mode on": "开启计划模式", + "Turn plan mode off": "关闭计划模式", + "Pi proposes, changes nothing": "Pi 只提出方案,不做修改", + "Unarchive session": "取消归档会话", + "Archive session": "归档会话", + "sign in to model providers": "登录模型提供商", + dark: "深色", + move: "移动", + open: "打开", + close: "关闭", + actions: "操作", + themes: "主题", + "Notifications were not allowed.": "未获准发送通知。", + "Checking this device": "正在检查此设备", + "The page. Click to use it; once focused, keys go to the page.": + "网页。点击即可操作;获得焦点后,按键会发送到网页。", + "Show hidden": "显示隐藏项", + "In a git worktree of its own: a branch, apart from this folder": + "在独立的 Git 工作树中:使用一个与此文件夹分开的分支", + Opening: "正在打开", + "The files it links to (styles, scripts, images) do not load here.": + "此处不会加载它引用的文件(样式、脚本和图片)。", + Add: "添加", + "no limit": "不限额", + Change: "更改", + Set: "设置", + Version: "版本", + "This list": "此快捷键列表", + "This SVG could not be drawn.": "无法绘制此 SVG。", + "Asking git…": "正在查询 Git…", + "Viewers can see the branch, not switch it.": "只读成员可以查看分支,但不能切换。", + "Take turns is on: the driver switches branches.": "轮流操作已开启:由当前控制者切换分支。", + "Pi is working: switch once it is done, or stop it.": + "Pi 正在工作:请等待完成或先停止任务,再切换分支。", + "Adding it up": "正在计算用量", + Message: "消息", + Reply: "回复", + "Pi gets the edited message in a new session that forks just before the original. The original stays as it is.": + "Pi 会在新会话中收到编辑后的消息;新会话从原消息之前分叉。原会话保持不变。", + "Send in a new session": "在新会话中发送", + "Ask Pi to commit, merge, or open a pull request when it is done. Removing the worktree deletes its folder; the branch and its commits stay, and Pi works in the original folder again.": + "完成后可以让 Pi 提交、合并或创建拉取请求。移除工作树会删除其文件夹;分支及提交会保留,Pi 将回到原始文件夹工作。", + "The worktree has uncommitted changes. Removing it anyway loses them.": + "工作树中有未提交的更改。仍然移除会丢失这些更改。", + "Make the branch": "新建分支", + ", with your uncommitted changes": ",并带上未提交的更改", + gone: "已删除", + here: "此处", + "Run its scripts in a sandbox, as an artifact runs: it can reach the internet, not the app": + "在沙盒中运行脚本(与成果运行方式相同):可以访问互联网,但不能访问应用", + "Mention it in the message box": "在消息框中提及此文件", + Mention: "提及", + "You were invited to one session: switching the branch changes the whole folder.": + "你仅获邀加入一个会话;切换分支会更改整个文件夹。", + "Edit, send again, or copy": "编辑、重新发送或复制", + "Someone else has to allow this: it came from your message. You can deny it.": + "此调用由你的消息触发,必须由其他人批准。你仍可拒绝。", + "This subagent has no messages yet.": "此子代理还没有消息。", + "That message is in earlier history: use “Show earlier messages” at the top.": + "该消息位于更早的历史记录中:请使用顶部的“显示更早消息”。", + You: "你", + "Write the plan here…": "在此写下计划…", + "Peek tiles on: sessions show here while they work, wait for you, or finish.": + "会话速览已开启:正在运行、等待你处理或刚完成的会话会显示在这里。", + "Hide peek tiles (Alt+P)": "隐藏会话速览(Alt+P)", + "Peek at other sessions (Alt+P)": "查看其他会话(Alt+P)", + "See the whole command where it waits": "查看等待批准的完整命令", + "Other sessions show here while they work, wait for you, or finish.": + "其他会话运行中、等待你处理或完成后会显示在这里。", + "Pinned sessions stay here. Alt+P hides this.": "置顶会话会保留在这里。按 Alt+P 可隐藏。", + Diff: "差异", + "Pi Pocket shares Pi's sign-ins": "Pi Pocket 与 Pi 共用登录信息", + "API key": "API 密钥", + "A guest cannot allow a call that their own message led to. You always can.": + "访客不能批准由自己发送的消息所触发的调用;你始终可以批准。", + "Only the owner can change extensions.": "只有所有者可以修改扩展。", + required: "必需", + "Add your own:": "添加自定义扩展:", + on: "开启", + "Turn off Lancet Guard in Pi Pocket?\n\nbash, write, and edit calls will run without checks in every session here. Pi's own setting stays as it is.": + "要在 Pi Pocket 中关闭 Lancet Guard 吗?\n\n这里所有会话中的 bash、write 和 edit 调用都将不经检查。Pi 自身的设置保持不变。", + "drop-in": "外置扩展", + "signed in": "已登录", + "not configured": "未配置", + "Log out": "退出登录", + "Open the sign-in page": "打开登录页面", + "Enter this code at": "在此处输入此代码:", + Continue: "继续", + "Waiting for the provider": "正在等待服务提供方", + "Pi gets these at their time, also when nobody is here, and the people in this session get a notification when it is done.": + "即使无人在线,Pi 也会在指定时间收到这些消息;完成后,本会话成员会收到通知。", + "in 2h check the deploy": "2 小时后检查部署", + "Start with when: in 30m, 7:00, tomorrow 9am, fri 17:30, every 2h, every weekday 8:00. Then what Pi gets.": + "先写时间:30 分钟后、7:00、明天 9 点、周五 17:30、每 2 小时、每个工作日 8:00;然后写 Pi 要收到的内容。", + next: "下次执行", + "set by": "设置者", + someone: "某人", + "Alt ↓: previous or next session": "Alt ↓:上一个或下一个会话", + "The new folder's name": "新文件夹名称", + "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes.": + "请在另一台设备上扫描此码或发送链接。该设备将以所有者身份登录;邀请仅可使用一次,并在 15 分钟后过期。", + "Making an invite": "正在创建邀请", + "Change what they can do": "更改其权限", + "Let steer": "允许操控", + "Let them see every session": "允许查看所有会话", + "here now": "当前在线", + away: "暂离", + "follows your Omarchy theme": "跟随你的 Omarchy 主题", + "Sign in with ChatGPT": "使用 ChatGPT 登录", + Someone: "某人", + "π only": "仅显示 Pi 的更改", + "Back (Alt+E)": "返回(Alt+E)", + "Running Pi Pocket on an Omarchy desktop adds “Desktop”: the app follows the theme the desktop uses.": + "在 Omarchy 桌面上运行 Pi Pocket 时,会额外出现“桌面”主题选项;应用会跟随桌面主题。", + "no commits yet": "尚无提交", + "Create it": "创建", + "same model": "相同模型", + "New sessions get a git worktree of their own": "每个新会话都使用独立的 Git 工作树", + "Seeing one session in the app does not limit what Pi can reach on the machine.": + "应用中只显示一个会话,并不限制 Pi 在此设备上可访问的内容。", + In: "会话范围", + "Local network, Cloudflare Tunnel, or Tailscale": "本地网络、Cloudflare Tunnel 或 Tailscale", + "Or enter it on the other device's sign-in screen.": "也可以在另一台设备的登录页面中输入。", + "not seen yet": "尚无上线记录", + Remove: "移除", + Pi: "Pi", + "In the launcher, start with > for actions, @ for sessions, or # for themes. Arrowing onto a theme shows it; Enter keeps it.": + "在启动器中,输入 > 搜索操作、@ 搜索会话、# 搜索主题。用方向键选中主题可预览,按 Enter 应用。", + "Main agent": "主代理", + scheduled: "已安排", + "Use theirs": "采用对方版本", + "unsaved changes": "未保存的更改", + "Send to Pi": "发送给 Pi", + "15 min": "15 分钟", + "1 hour": "1 小时", + "1 day": "1 天", + "1 week": "1 周", + "in 15 minutes": "15 分钟", + "in an hour": "1 小时", + "in a day": "1 天", + "in a week": "1 周", + "every session": "所有会话", + "Put a .ts extension module in": "将 .ts 扩展模块放入", + "Plan mode is on: Pi proposes a plan and changes nothing until you approve it.": + "计划模式已开启:Pi 会提出计划,在你批准前不会进行任何更改。", + "Plan mode is off.": "计划模式已关闭。", + "follows your desktop": "跟随桌面设置", + "mentioned you": "提到了你", + "Say which command to run, such as !git status.": "请说明要运行的命令,例如 !git status。", + "Server unreachable: ": "无法连接服务器:", + "when a run you took part in ends": "当你参与的运行结束时", + "when Lancet Guard asks before a command; allow or deny it from the notification": + "Lancet Guard 在命令执行前请求审批时;可直接通过通知允许或拒绝", + "when someone writes @your name in a chat": "有人在聊天中 @提及你时", + "in sessions you take part in": "在你参与的会话中", + "Get a notification on this device when you are not looking at the session: Pi finished, Pi needs approval, or someone wrote to you.": + "未查看会话时,此设备会在 Pi 完成任务、需要审批或有人给你发消息时发送通知。", + "Browser (Alt+B)": "浏览器(Alt+B)", + "View only.": "仅供查看。", + "You can read along, react, and chat with the people here.": + "你可以旁观、回应并与此处的成员聊天。", + "Plan mode: Pi reads and proposes, and changes nothing until you approve": + "计划模式:Pi 会阅读并提出方案,待你批准后才会更改内容", + "Send as": "发送方式", + "Stop (Esc twice)": "停止(按两次 Esc)", + files: "个文件", + selected: "已选", + "Sessions and tools": "会话与工具", + "Subagents in every session": "所有会话中的子代理", + "Default model": "默认模型", + "the last one picked": "上次选择的模型", + "for new sessions": "用于新会话", + default: "默认", + "Spend goes to whoever asked for the work. Past a limit, Pi takes no new messages there, a run that crosses it stops, and subagents' reports wait until it is raised.": + "费用计入发起任务的成员或会话。达到限额后,Pi 将不再接受该成员或会话的新消息;运行中超限的任务会停止,子代理的报告则会等到提高限额后再发送。", + "Fork, retry": "分叉或重试", + "Continued from Pi in the terminal": "已从终端中的 Pi 会话继续", + "Press Esc twice": "连续按两次 Esc", + "Esc stops": "按两次 Esc 可停止", + "[what to keep]": "[保留内容]", + "[handoff note]": "[交接说明]", + "[text]": "[文本]", + "[name]": "[名称]", + "[level]": "[级别]", + "[folder]": "[文件夹]", + "[title]": "[标题]", + " ": "<时间> <消息>", + "": "<经验内容>", + "": "<命令>", + "[PR number or URL]": "[PR 编号或 URL]", + "[address]": "[地址]", + "[message]": "[消息]", +}; + +const templates: Record = { + "Invite code {{code}}": "邀请码 {{code}}", + "pinned by {{name}} {{when}}": "由 {{name}} 于 {{when}} 置顶", + "{{count}} message waiting for Pi": "{{count}} 条消息等待 Pi 处理", + "{{count}} messages waiting for Pi": "{{count}} 条消息等待 Pi 处理", + " with “{{query}}”": "(包含“{{query}}”)", + "And {{count}} more changed file, not listed here.": "另有 {{count}} 个已更改文件未在此列出。", + "And {{count}} more changed files, not listed here.": "另有 {{count}} 个已更改文件未在此列出。", + "Services post to {{origin}}, so they must be able to reach it: a tunnel's address works for GitHub. ": + "服务向 {{origin}} 推送,因此必须能够访问此地址;GitHub 可使用隧道地址。 ", + "{{count}} tabs": "{{count}} 个标签页", + "{{used}} of {{total}} tokens in context": "上下文已用 {{used}}/{{total}} token", + "cache {{percent}}%": "缓存命中 {{percent}}%", + "on {{branch}}": "当前分支 {{branch}}", + "in {{seconds}}s": "{{seconds}} 秒后", + "Attempt {{count}}, after: {{error}}": "第 {{count}} 次尝试,上次错误:{{error}}", + "Session {{id}}": "会话 {{id}}", + "{{count}} more, finished earlier: open the session to see them": + "另有 {{count}} 个较早完成的子代理:打开会话查看", + "{{path}} and the folders in it": "{{path}} 及其子文件夹", + "Pi has its skills from now on: {{skills}}.": "Pi 从现在起可使用这些技能:{{skills}}。", + "Trusted {{where}}.": "已信任{{where}}。", + "{{name}} turned {{extension}} on.": "{{name}} 已启用 {{extension}}。", + "{{name}} turned {{extension}} off.": "{{name}} 已关闭 {{extension}}。", + "{{name}} made approvals need someone other than who asked.": + "{{name}} 已要求由请求者以外的成员批准。", + "{{name}} let anyone who can steer allow risky calls.": + "{{name}} 已允许所有可操控成员批准高风险调用。", + "PiP's context was not compacted: {{error}}": "PiP 上下文未压缩:{{error}}", + "Could not attach {{name}}: {{error}}": "无法附加 {{name}}:{{error}}", + "Could not send {{path}}: {{error}}": "无法发送 {{path}}:{{error}}", + "{{count}} file": "{{count}} 个文件", + "{{size}} · tap for {{preset}}": "{{size}} · 点击切换为{{preset}}", + "Thinking can be {{levels}}.": "可用的思考级别:{{levels}}。", + "{{model}} does not think.": "{{model}} 不支持思考。", + "context {{percent}}% of {{tokens}}": "上下文已用 {{percent}}%,容量 {{tokens}}", + "{{name}} asks to drive": "{{name}} 申请接管", + "{{name}} is driving": "{{name}} 正在掌舵", + "{{name}} is driving (away)": "{{name}} 正在掌舵(暂离)", + "passes (check {{tries}} of {{max}}).": "已通过(第 {{tries}} 次检查,共 {{max}} 次)。", + "still fails after {{max}} checks.": "在 {{max}} 次检查后仍未通过。", + "check {{tries}} of {{max}} failed": "第 {{tries}} 次检查失败,共 {{max}} 次", + "had more to tell than {{max}} messages: watch stopped.": + "待报告内容超过 {{max}} 条消息,已停止监视。", + "could not be watched: {{error}}.": "无法监视:{{error}}。", + "{{sent}} of {{max}} sent": "已发送 {{sent}}/{{max}} 条", + "Model: {{model}}, thinking {{level}}": "模型:{{model}},思考级别:{{level}}", + "Model: {{model}}": "模型:{{model}}", + "Model and thinking: {{model}}{{level}}": "模型与思考级别:{{model}}{{level}}", + "{{layout}}, sidebar {{sidebar}}": "{{layout}},侧栏{{sidebar}}", + "{{count}} running": "{{count}} 项正在运行", + "{{used}} of {{total}} tokens in PiP's context.": "PiP 上下文已使用 {{used}}/{{total}} token。", + "PiP compacts itself above {{tokens}} once idle.": + "上下文超过 {{tokens}} 后,PiP 会在空闲时自动压缩。", + "Context {{percent}}% full": "上下文已用 {{percent}}%", + "Hi {{name}}. I’m PiP.": "你好,{{name}}。我是 PiP。", + "PiP waits for your approval: {{tool}}": "PiP 正在等待你批准:{{tool}}", + "Unfold the sidebar ({{keys}})": "展开侧栏({{keys}})", + "Fold the sidebar ({{keys}})": "折叠侧栏({{keys}})", + "Sessions · fold to numbers ({{keys}})": "会话 · 折叠为编号({{keys}})", + "{{count}} at work": "{{count}} 个正在工作", + "{{count}} failed": "{{count}} 个失败", + "{{count}} post, last {{when}}": "收到 {{count}} 次推送,最近一次:{{when}}", + "{{count}} posts, last {{when}}": "收到 {{count}} 次推送,最近一次:{{when}}", + "label {{label}}": "标签 {{label}}", + "CI on {{branch}}": "{{branch}} 分支的 CI", + "{{count}} sent, last {{when}}": "已发送 {{count}} 次,最近一次:{{when}}", + "Remove the webhook “{{name}}”? Posts to its address stop reaching Pi.": + "移除 Webhook“{{name}}”?发送到其地址的推送将不再传给 Pi。", + "A new secret for “{{name}}”: posts with the old one stop getting in. {{where}}": + "为“{{name}}”生成新密钥:旧密钥将不再接收推送。{{where}}", + "Stop watching {{repo}}? Nothing more comes from it.": + "停止监视 {{repo}}?将不再接收来自它的动态。", + "Let anyone's issues and PRs on {{repo}} reach {{target}}? Strangers' words then come with your instructions.": + "允许 {{repo}} 中任何人的议题和 PR 传给 {{target}}?陌生人的内容将和你的指令一起传入。", + "Pi in “{{title}}” gets each post.": "每次推送都会传给“{{title}}”中的 Pi。", + "{{count}} here": "{{count}} 人在线", + "{{count}} everywhere": "所有项目 {{count}} 条", + "{{count}} to approve": "{{count}} 条待批准", + "for {{name}}": "代表 {{name}}", + "for {{scope}}": "适用于{{scope}}", + "{{name}} proposes forgetting it.": "{{name}} 提议忘记这条经验。", + "{{name}} proposes:": "{{name}} 提议:", + "Those for {{project}} go to every session in it, worktrees too; the rest go everywhere.": + "{{project}} 的经验适用于该项目的所有会话(包括工作树);其余经验适用于所有项目。", + "To approve · {{count}}": "待批准 · {{count}} 条", + "{{what}}, for {{scope}}, waits for {{who}}:": "{{what}},适用于{{scope}},等待{{who}}批准:", + "With {{model}}": "使用 {{model}}", + "{{tokens}} context": "上下文容量 {{tokens}}", + "{{count}} message": "{{count}} 条消息", + "{{count}} messages": "{{count}} 条消息", + "No session matches “{{query}}”.": "没有匹配“{{query}}”的会话。", + "on {{date}}": "于 {{date}}", + "Waiting for approval: {{tool}} {{subject}}": "等待批准:{{tool}} {{subject}}", + "{{count}} in {{sessions}} session": "{{sessions}} 个会话中有 {{count}} 个子代理", + "{{count}} in {{sessions}} sessions": "{{sessions}} 个会话中有 {{count}} 个子代理", + "{{count}} subagent": "{{count}} 个子代理", + "{{count}} subagents": "{{count}} 个子代理", + "Stop {{name}}": "停止 {{name}}", + "Reports wait: {{reason}}.": "报告正在等待:{{reason}}。", + "Subagents: {{summary}}": "子代理:{{summary}}", + "Its skills in .agents/skills ({{skills}}) load only in a project Pi trusts.": + "仅当 Pi 信任此项目时,才会加载其 .agents/skills 中的技能({{skills}})。", + "In .agents/skills: {{skills}}. {{status}}": ".agents/skills 中的技能:{{skills}}。{{status}}", + "In .pi/skills: {{skills}}. {{status}}": ".pi/skills 中的技能:{{skills}}。{{status}}", + "{{status}}, as the folder it is in: {{path}}.": "{{status}},继承自上级文件夹:{{path}}。", + "Dictation stopped: {{error}}.": "听写已停止:{{error}}。", + "Dictation did not start: {{error}}": "无法开始听写:{{error}}", + "latest is v{{version}}": "最新版本为 v{{version}}", + "Show {{count}} hidden lines": "显示隐藏的 {{count}} 行", + "Show all {{count}} hidden lines": "显示全部隐藏的 {{count}} 行", + "A large diff: {{count}} changed lines.": "差异较大:{{count}} 行发生变化。", + "Layout: {{layout}}. Tap for {{next}} (s)": "布局:{{layout}}。点击切换为{{next}}(s)", + "{{count}} files": "{{count}} 个文件", + "{{seen}}/{{total}} viewed": "已查看 {{seen}}/{{total}}", + "{{seen}} of {{total}} viewed": "已查看 {{seen}} / 共 {{total}}", + "No uncommitted changes{{branch}}.": "没有未提交的更改{{branch}}。", + "Show all {{count}} lines": "显示全部 {{count}} 行", + "{{count}} older branches are not listed.": "还有 {{count}} 个更早的分支未列出。", + "Size: {{size}}. Switch to {{preset}}": "尺寸:{{size}}。切换到{{preset}}", + "{{count}} lines": "{{count}} 行", + "Only the first {{count}} are listed.": "仅列出前 {{count}} 项。", + "Only the first {{size}} are shown.": "仅显示前 {{size}}。", + "{{name}} is gone": "{{name}} 已不存在", + "Against {{name}}": "相对于 {{name}}", + "On {{branch}} now.": "当前已切换到 {{branch}}。", + "from {{head}}{{changes}}": "从 {{head}}{{changes}} 创建", + "{{count}} file(s) have uncommitted changes: they come along, unless the other branch changes them too.": + "有 {{count}} 个文件包含未提交的更改:这些更改会一并保留,除非目标分支也修改了这些文件。", + "No branch matches “{{query}}”.": "没有匹配的分支:“{{query}}”。", + "Open {{path}}": "打开 {{path}}", + "Sent with the message: {{path}}": "随消息发送:{{path}}", + "{{tool}} image": "{{tool}} 图片", + "version {{version}}": "版本 {{version}}", + "Lancet Guard asks before this {{tool}} call": "Lancet Guard 正在请求批准此 {{tool}} 调用", + "Forked from {{title}}": "从 {{title}} 分支而来", + "Chat with {{names}}": "与 {{names}} 聊天", + "{{name}} saved the notes while you were editing.": "你编辑期间,{{name}} 保存了备注。", + "Saved by {{name}} {{when}}": "由 {{name}} 保存,{{when}}", + "Open {{title}}": "打开 {{title}}", + "Off in Pi's own settings ({{path}}), so it does not run here either.": + "Pi 自身设置中的 {{path}} 已关闭,因此这里也不会运行。", + "{{title}}: {{state}}": "{{title}}:{{state}}", + "Turn on {{title}}?\n\nA drop-in extension runs inside the server with your rights, in every session.": + "要启用 {{title}} 吗?\n\n外置扩展会在服务器内以你的权限运行,并作用于所有会话。", + "tools: {{tools}}": "工具:{{tools}}", + "{{count}} models": "{{count}} 个模型", + "Log out of {{provider}}? Pi uses the same sign-in, so it is signed out too.": + "要退出 {{provider}} 的登录吗?Pi 使用相同的登录信息,因此也会一并退出。", + "Sign in: {{provider}}": "登录:{{provider}}", + "{{count}} waiting for approval": "{{count}} 项待批准", + "This session works on the branch {{branch}}, in a checkout of its own: its changes stay apart from {{source}}.": + "此会话在独立检出的分支 {{branch}} 上工作;其更改与 {{source}} 分开保存。", + "Removed the worktree. The branch {{branch}} stays.": + "已移除 worktree。分支 {{branch}} 会保留。", + "The oldest history was not searched: only the newest {{count}} earlier entries.": + "未搜索最早的历史记录:仅搜索了较新的 {{count}} 条历史记录。", + "New folder in {{folder}}": "在 {{folder}} 中新建文件夹", + "{{path}} isn't there yet.": "{{path}} 尚不存在。", + "Desktop · {{theme}}": "桌面 · {{theme}}", + "{{count}} coming": "{{count}} 项待执行", + "Back to sessions, {{count}} waiting for you": "返回会话列表,{{count}} 项待你处理", + "No model matches “{{query}}”.": "没有匹配的模型:“{{query}}”。", + "No file matches “{{query}}”.": "没有匹配的文件:“{{query}}”。", + "limit {{amount}}": "限额 {{amount}}", + "Pi spent {{amount}} on this server so far.": "Pi 在此服务器上的累计支出为 {{amount}}。", + "Remove {{name}}? Their devices are signed out.": "移除 {{name}}?其设备将退出登录。", + "Type an address above, such as {{address}}, or ask Pi to open one.": + "在上方输入网址,例如 {{address}},或让 Pi 打开网页。", + "Nothing here says “{{query}}”.": "这里没有提到“{{query}}”。", + "Nothing matches “{{query}}”.": "没有匹配项:“{{query}}”。", + "No theme called “{{name}}”. Try {{themes}}, or desktop.": + "找不到名为“{{name}}”的主题。可选主题:{{themes}},或 desktop。", + "{{branch}} is checked out in {{path}}: git switches to it only there.": + "{{branch}} 已在 {{path}} 中检出;Git 只能在那里切换。", + "in another worktree: {{path}}": "位于另一个 worktree:{{path}}", + "Approvals need someone else: {{state}}": "审批需由其他人批准:{{state}}", + "Other devices connect through {{label}}.": "其他设备通过 {{label}} 连接。", + "This link only works on this device. To let other devices in, press {{key}} in the Pi Pocket terminal (or start it with {{flag}}) and pick {{choices}}. Then make a new invite.": + "此链接只能在本设备上使用。若要允许其他设备加入,请在 Pi Pocket 终端中按 {{key}}(或使用 {{flag}} 启动),然后选择 {{choices}}。之后请重新创建邀请。", + "seen {{time}}": "上次出现:{{time}}", + "{{time}} ago": "{{time}}前", + "only {{sessions}}": "仅限 {{sessions}}", + "switched the model to {{model}}": "切换了模型:{{model}}", + "{{label}}, {{count}} {{kind}}": "{{label}},{{count}} {{kind}}", + "{{label}}, a page open": "{{label}},有页面已打开", + "Scan this on the other device, or send it the link. It works once and expires {{lasting}}. Whoever joins sees {{where}}.": + "请在另一台设备上扫描此码或发送链接。该链接仅可使用一次,并于 {{lasting}}后过期。加入者可以访问:{{where}}。", + "only “{{title}}”": "仅限“{{title}}”", + "Scheduled for {{when}}.": "计划于 {{when}}。", + "Pi keeps going until {{command}} passes, checked after each answer.": + "Pi 将持续运行,直到 {{command}} 通过;每次回答后都会检查。", + "Theme: {{theme}}.": "主题:{{theme}}。", + "Signed in to {{provider}}.": "已登录到 {{provider}}。", + "Archived {{what}}. Tap to undo.": "已归档 {{what}}。点击撤销。", + "Unarchived {{what}}. Tap to undo.": "已取消归档 {{what}}。点击撤销。", + "{{count}} sessions": "{{count}} 个会话", + "{{count}} new chat messages": "收到 {{count}} 条新聊天消息", + "{{count}} device of yours gets them.": "你的 {{count}} 台设备会收到通知。", + "{{count}} devices of yours get them.": "你的 {{count}} 台设备会收到通知。", + "Deleted {{path}}.": "已删除 {{path}}。", + "{{path}} is as the last commit has it.": "文件 {{path}} 已恢复为最近一次提交中的状态。", + "Nothing answers at that address ({{error}}). Is the server running?": + "该地址没有响应({{error}})。服务器是否正在运行?", + "Changes: {{counted}}, {{added}} lines added and {{removed}} removed": + "更改:{{counted}},新增 {{added}} 行,删除 {{removed}} 行", + "Changes: {{counted}}": "更改:{{counted}}", + "{{title}}, in {{folder}}: session menu": "会话菜单:{{title}}({{folder}})", + "{{title}}: session menu": "会话菜单:{{title}}", + "subagent of {{title}}": "属于 {{title}} 的子代理", + "No branch, at {{branch}}: switch to one": "无分支(当前位于 {{branch}}):切换或新建分支", + "Branch {{branch}}: switch or make a branch": "分支 {{branch}}:切换或新建分支", + "Subagents: {{count}} working": "子代理:{{count}} 个正在工作", + "Remove {{name}}": "移除 {{name}}", + "Running now, {{count}}": "正在运行:{{count}} 项", + "{{label}}, {{detail}}": "{{label}}:{{detail}}", + "Open {{name}}": "打开 {{name}}", + "{{decision}} by {{name}}": "{{name}}:{{decision}}", +}; + +export default function zhCnUi() { + return { + ...defineExtension({ name: "zh-cn-ui" }), + uiLocales: [{ locale: "zh-CN", label: "简体中文", default: true, strings, templates }], + }; +} diff --git a/docs/extensions.md b/docs/extensions.md index 3c89a2aa..c0917f8b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -58,6 +58,12 @@ The file: ### UI locale packs (data only) +[`examples/zh-cn-ui.ts`](examples/zh-cn-ui.ts) is an optional Simplified Chinese pack. Copy it into the data folder's `extensions/` directory, then turn it on in **Menu → Extensions**. Like other drop-ins, it starts off. Turning it off restores English, including in already-open panels. It adds no tools or hooks and does not change the model's language or a person's content. + +The pack covers fixed application strings, labels, and named templates, not every surface: pre-sign-in pages, persisted activity, raw provider/tool errors, and some dynamic status summaries remain English. Missing keys use their English source. Optional server notice metadata supplies a key and parameters for presentation only; its original message and log remain unchanged. + +This separate, data-only path translates fixed Pocket UI. It does not translate user-authored text, model output, provider names, or arbitrary HTML. + A Pocket extension may also return optional `uiLocales` data alongside its ordinary tools, sections, hooks, or tasks. The shape is: ```typescript diff --git a/docs/map.md b/docs/map.md index 6bd0ae48..a2443ffb 100644 --- a/docs/map.md +++ b/docs/map.md @@ -28,7 +28,7 @@ A module split into parts keeps its name for the part others import, with the re | `entry-format.ts` | The kinds of Pi Pocket's own entries, and the markers it writes into message text (`[from: …]`, attached files). Stored data and the web app depend on their exact values | | `fence.ts` | Text from elsewhere between tags as data, for Pi: no closing tag survives inside it | | `docs.ts` | Every durable document Pi Pocket defines | -| `host.ts`, `reload.ts` | `PocketHost` (what extensions get) and `Approvals`; the extension loader: built-ins in `ORDER`, drop-ins (never with a name Pi Pocket keeps, `BUILT_IN_TOOLS`), live reload | +| `host.ts`, `reload.ts` | `PocketHost` (what extensions get) and `Approvals`; the extension loader: built-ins in `ORDER`, drop-ins (never with a name Pi Pocket keeps, `BUILT_IN_TOOLS`), live reload; validates and collects optional data-only `uiLocales` | | `lock.ts` | One process per data folder | | `errors.ts`, `paths.ts` | `HttpError`, `describe`, and input checks; `~` paths | @@ -125,9 +125,10 @@ Plain ES modules, loaded as they are. `store.js` holds the shared state, and not | File | Owns | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store.js` | State, the event stream (SSE, or long polling when a tunnel holds it back), `api()`, `actions` | +| `store.js` | State, the event stream (SSE, or long polling when a tunnel holds it back), `api()`, `actions`; applies locale packs from `hello` | | `back.js` | The back stack: routes and the layers over them (sheets, the drawer, panels that cover the screen, a file over the tree) as history entries, so back closes what opened last; `useBack`, `pushRoute`, `goHome`, the list put under a conversation opened straight away, taps let go that land on what just slid in | | `gestures.js` | Touch gestures, each following its own finger by its identifier, passive where they can be: sheets dragged down and the drawer dragged left to close (`useDragToClose`); on phones, swipes on the conversation (right back, left Files), in Files (right back), and up from the message box (the places); a swipe in from the left edge goes back in an iPhone's home screen app | +| `i18n.js` | Explicit-key UI translations and placeholder templates; English fallback | | `app.js` | Layout (tiled windows, and the subagents board in the conversation's place), top bar (with what changed, from `diff.js`), notices, keyboard shortcuts, routing | | `transcript.js` | The conversation as turns: messages, Pi's replies, its thinking and tool calls folded into groups that open into a timeline, approvals, the live line, breadcrumbs | | `turns.js` | How the transcript reads a conversation, without drawing it: answers split into runs of words and groups of calls (`itemsOf`), a group in a line (`summarize`), and what a call came to from what its tool printed. No imports, so Node tests it | diff --git a/src/server/app.ts b/src/server/app.ts index 0c5036b4..a96e1a6d 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -409,7 +409,8 @@ export class PocketApp { subagentModels: () => this.config.subagentModels, modelProblem: (ref, fresh) => this.providers.problem(ref, fresh), requesterOf: (conversationId) => this.attribution.requesterOf(conversationId), - notice: (level, message, conversationId) => this.notice(level, message, conversationId), + notice: (level, message, conversationId, ui) => + this.notice(level, message, conversationId, ui), heldBack: (conversationId) => this.spend.heldBack(conversationId), onLimitsChanged: (listener) => this.spend.onLimitsChanged(listener), schedules: this.schedules, @@ -791,6 +792,7 @@ export class PocketApp { level: "info" | "warning" | "error", message: string, conversationId?: ConversationId, + ui?: { key: string; params?: Record }, ): void { this.#log(`[${level}] ${message}`); @@ -805,7 +807,7 @@ export class PocketApp { this.canSee(client.user, conversationId); if (reaches) { - client.send("notice", { level, message }); + client.send("notice", { level, message, ...(ui === undefined ? {} : { ui }) }); } } } @@ -2183,6 +2185,13 @@ export class PocketApp { this.notice( enabled ? "info" : "warning", `${user.name} turned ${module.title} ${enabled ? "on" : "off"}.`, + undefined, + { + key: enabled + ? "{{name}} turned {{extension}} on." + : "{{name}} turned {{extension}} off.", + params: { name: user.name, extension: module.title }, + }, ); } @@ -2227,6 +2236,14 @@ export class PocketApp { rule === "others" ? `${user.name} made approvals need someone other than who asked.` : `${user.name} let anyone who can steer allow risky calls.`, + undefined, + { + key: + rule === "others" + ? "{{name}} made approvals need someone other than who asked." + : "{{name}} let anyone who can steer allow risky calls.", + params: { name: user.name }, + }, ); } diff --git a/src/server/commands.ts b/src/server/commands.ts index f87fbc4a..230a5365 100644 --- a/src/server/commands.ts +++ b/src/server/commands.ts @@ -724,7 +724,10 @@ export class Commands { try { parts.push(await modelImage(file.path, file.mime)); } catch (error) { - app.notice("warning", `Could not attach ${file.name}: ${describe(error)}`, id); + app.notice("warning", `Could not attach ${file.name}: ${describe(error)}`, id, { + key: "Could not attach {{name}}: {{error}}", + params: { name: file.name, error: describe(error) }, + }); } } @@ -940,7 +943,10 @@ export class Commands { text: `${FILE_BLOCK}${file}">\n${content}${cut}\n`, }); } catch (error) { - app.notice("warning", `Could not send ${mention.written}: ${describe(error)}`, id); + app.notice("warning", `Could not send ${mention.written}: ${describe(error)}`, id, { + key: "Could not send {{path}}: {{error}}", + params: { path: mention.written, error: describe(error) }, + }); } } @@ -1932,7 +1938,9 @@ export class Commands { const taskId = await conversation.compact(instructions, context); const done = pip ? "PiP's context compacted." : "Compacted."; - app.notice("info", pip ? "Compacting PiP's context…" : "Compacting…", id); + app.notice("info", pip ? "Compacting PiP's context…" : "Compacting…", id, { + key: pip ? "Compacting PiP's context…" : "Compacting…", + }); await app.collab.activity(id, user, "started compacting the context", false); void app.harness.waitForTask(taskId, context).then( (receipt) => { @@ -1944,7 +1952,10 @@ export class Commands { const failed = pip ? app.pipCompaction.failure(Number(taskId)) : undefined; if (failed !== undefined) { - app.notice("error", `PiP's context was not compacted: ${failed}`, id); + app.notice("error", `PiP's context was not compacted: ${failed}`, id, { + key: "PiP's context was not compacted: {{error}}", + params: { error: failed }, + }); } else { app.notice( "info", @@ -1952,10 +1963,16 @@ export class Commands { ? "Nothing to compact: the context fits in the recent window." : done, id, + { + key: + entryId === undefined && submissionId === undefined + ? "Nothing to compact: the context fits in the recent window." + : done, + }, ); } } else if (outcome.status === "aborted") { - app.notice("info", "Compaction aborted.", id); + app.notice("info", "Compaction aborted.", id, { key: "Compaction aborted." }); } else { const why = "error" in outcome ? outcome.error?.message : undefined; diff --git a/src/server/host.ts b/src/server/host.ts index 60d28e92..29d66d41 100644 --- a/src/server/host.ts +++ b/src/server/host.ts @@ -160,6 +160,7 @@ export interface PocketHost { level: "info" | "warning" | "error", message: string, conversationId?: ConversationId, + ui?: { key: string; params?: Record }, ): void; /** * Why work Pi would start in a conversation with nobody asking (a subagent's report, say) may not start now: the diff --git a/test/docs-examples.test.ts b/test/docs-examples.test.ts index 98a328c8..191b5f3d 100644 --- a/test/docs-examples.test.ts +++ b/test/docs-examples.test.ts @@ -94,6 +94,7 @@ test("the example extensions load as drop-ins, off until the owner turns them on [ ["clock.ts", false], ["no-force-push.ts", false], + ["zh-cn-ui.ts", false], ], ); assert.ok(listed.every((module) => module.summary.length > 20)); diff --git a/test/i18n-notice.test.ts b/test/i18n-notice.test.ts new file mode 100644 index 00000000..92e70f86 --- /dev/null +++ b/test/i18n-notice.test.ts @@ -0,0 +1,38 @@ +// Optional locale metadata changes presentation only: English notices, reach and raw errors remain intact. +import { cleanUp, fakeTab, newSession, openApp, owner, scriptedModel } from "./helpers.ts"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { setUiLocales, t } from "../web/i18n.js"; + +test("server notice locale metadata is additive, scoped, and preserves parameters", async () => { + const app = await openApp(scriptedModel()); + const id = await newSession(app); + const otherId = await newSession(app); + const tab = fakeTab(id, owner(app)); + const other = fakeTab(otherId, owner(app)); + const params = { name: "Menu {{name}} /file", error: "EXTERNAL RAW {{name}}" }; + const key = "Could not attach {{name}}: {{error}}"; + const message = `Could not attach ${params.name}: ${params.error}`; + + try { + await app.attach(tab.client); + await app.attach(other.client); + app.notice("warning", message, id, { key, params }); + assert.deepEqual(tab.last("notice"), { level: "warning", message, ui: { key, params } }); + assert.equal(other.last("notice"), undefined); + setUiLocales([ + { locale: "zh-CN", strings: {}, templates: { [key]: "无法附加 {{name}}:{{error}}" } }, + ]); + assert.equal(t(key, params), `无法附加 ${params.name}:${params.error}`); + setUiLocales([]); + assert.equal(t(key, params), message); + app.notice("error", "EXTERNAL RAW {{name}}", id); + assert.deepEqual(tab.last("notice"), { level: "error", message: "EXTERNAL RAW {{name}}" }); + } finally { + setUiLocales([]); + app.detach(tab.client); + app.detach(other.client); + await app.close(); + cleanUp(); + } +}); diff --git a/test/i18n-ui.test.ts b/test/i18n-ui.test.ts index 08ebc172..086c8288 100644 --- a/test/i18n-ui.test.ts +++ b/test/i18n-ui.test.ts @@ -10,7 +10,7 @@ import { until, } from "./helpers.ts"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync } from "node:fs"; import { createServer, type Server } from "node:http"; import { join } from "node:path"; import { after, before, test } from "node:test"; @@ -26,30 +26,9 @@ const real = { skip: chromium === undefined ? "no Chromium-based browser on this machine" : false, } as const; const dataDir = join(root, "i18n-ui-data"); -const localeFile = "i18n-ui-fixture.ts"; +const localeFile = "zh-cn-ui.ts"; const draft = "Draft stays here while the interface changes."; -const localeModule = `/** A small test locale for the live UI translation test. */ -import { defineExtension } from "@earendil-works/pi-durable"; - -export default () => ({ - ...defineExtension({ name: "i18n-ui-fixture" }), - uiLocales: [ - { - locale: "zh-CN", - label: "简体中文", - default: true, - strings: { - Menu: "菜单", - Extensions: "扩展", - Appearance: "外观", - }, - templates: {}, - }, - ], -}); -`; - let app: App | undefined; let server: Server | undefined; let browsers: Browsers | undefined; @@ -67,7 +46,10 @@ before(async () => { const dropIns = join(dataDir, "extensions"); mkdirSync(dropIns, { recursive: true }); - writeFileSync(join(dropIns, localeFile), localeModule); + copyFileSync( + join(import.meta.dirname, "../docs/examples/zh-cn-ui.ts"), + join(dropIns, localeFile), + ); const active = await openApp(scriptedModel(), dataDir); app = active; @@ -101,7 +83,11 @@ before(async () => { ) === true, "the signed-in guest session", 20_000, - ); + ).catch((error) => { + throw new Error(`${String(error)}; browser logs: ${JSON.stringify(page.logs())}`, { + cause: error, + }); + }); } }); @@ -258,6 +244,32 @@ test( assertNoReloadAndNoDataChanges(state, initial); } + const rawError = "EXTERNAL RAW {{name}} "; + const attachment = "Menu {{name}} /some/path"; + const notice = `Could not attach ${attachment}: ${rawError}`; + + active.notice("error", notice, session, { + key: "Could not attach {{name}}: {{error}}", + params: { name: attachment, error: rawError }, + }); + active.notice("error", rawError, session); + await until(async () => { + const texts = await Promise.all( + [phonePage, computerPage].map((page) => + page.evaluate(`document.querySelector('.notices')?.textContent ?? ''`), + ), + ); + + return texts.every((text) => text.includes("无法附加") && text.includes(rawError)); + }, "both pages render structured notices without translating raw errors"); + + for (const page of [phonePage, computerPage]) { + assert.equal( + await page.evaluate(`document.querySelectorAll('.notices img').length`), + "0", + ); + } + const disabled = await postExtension(active.config.ownerToken, false); assert.equal(disabled.status, 200, "the owner disables the test locale through the API"); @@ -293,5 +305,22 @@ test( assert.equal(state.menuButton, "Menu", `${label} restores the English Menu control`); assertNoReloadAndNoDataChanges(state, initial); } + + for (const page of [phonePage, computerPage]) { + const noticeText = await page.evaluate( + `document.querySelector('.notices').textContent`, + ); + + assert.ok( + noticeText.includes(notice), + "open structured notice switches back to English", + ); + assert.ok(noticeText.includes(rawError), "raw external errors stay unchanged"); + assert.deepEqual( + page.logs().filter((entry) => entry.level === "error"), + [], + "no browser console errors", + ); + } }, ); diff --git a/test/zh-cn-ui.test.ts b/test/zh-cn-ui.test.ts new file mode 100644 index 00000000..467881d8 --- /dev/null +++ b/test/zh-cn-ui.test.ts @@ -0,0 +1,106 @@ +// The shipped example is exercised by the real loader and Chromium in i18n-ui.test.ts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import zhCnUi from "../docs/examples/zh-cn-ui.ts"; +import { setUiLocales, t } from "../web/i18n.js"; + +test("Chinese pack keeps template placeholders and dynamic values intact", () => { + const extension = zhCnUi(); + const pack = extension.uiLocales[0]!; + const slots = (text: string) => + [...new Set([...text.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))].sort(); + + assert.equal(extension.name, "zh-cn-ui"); + assert.equal(pack.locale, "zh-CN"); + assert.ok(Object.keys(pack.strings).length > 700); + assert.ok(Object.keys(pack.templates).length > 90); + + for (const [key, value] of Object.entries(pack.templates)) { + assert.deepEqual(slots(value), slots(key), key); + } + + try { + setUiLocales(extension.uiLocales); + assert.equal(t("Menu"), "菜单"); + assert.equal(t("Unregistered English UI"), "Unregistered English UI"); + const name = "Menu {{name}} /some/path"; + const error = "EXTERNAL RAW {{name}}"; + const translated = t("Could not attach {{name}}: {{error}}", { name, error }); + + assert.ok(translated.includes(name)); + assert.ok(translated.includes(error)); + assert.notEqual(translated, `Could not attach ${name}: ${error}`); + } finally { + setUiLocales([]); + } + + assert.equal(t("Menu"), "Menu"); +}); + +test("Chinese pack covers literal and conditional t() keys in the web app", () => { + const pack = zhCnUi().uiLocales[0]!; + const keys = new Set([...Object.keys(pack.strings), ...Object.keys(pack.templates)]); + const missing = new Set(); + let checked = 0; + + const literals = (node: ts.Node): string[] => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return [node.text]; + } + + if (ts.isConditionalExpression(node)) { + return [...literals(node.whenTrue), ...literals(node.whenFalse)]; + } + + return []; + }; + + const scan = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + + if (entry.isDirectory()) { + scan(path); + } else if (entry.name.endsWith(".js")) { + const file = ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "t" && + node.arguments[0] + ) { + for (const key of literals(node.arguments[0])) { + checked++; + + if (!keys.has(key)) { + missing.add(key); + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(file); + } + } + }; + + scan(join(import.meta.dirname, "../web")); + assert.ok(checked > 900); + assert.deepEqual( + [...missing], + [], + "indirect expressions and untranslated surfaces still require review", + ); +}); diff --git a/web/app.js b/web/app.js index 7d3a4ed2..d77e299a 100644 --- a/web/app.js +++ b/web/app.js @@ -258,7 +258,7 @@ function Notices() { notice.action?.(); }} > - ${notice.message} + ${notice.ui ? t(notice.ui.key, notice.ui.params) : notice.message} `; })} `; @@ -312,7 +312,7 @@ function App() { if (state.me === undefined) { return html`<${Boot} - caption=${state.notices.some((notice) => notice.level === "error") ? "waiting for the server" : "starting"} + caption=${t(state.notices.some((notice) => notice.level === "error") ? "waiting for the server" : "starting")} /> <${Notices} />`; } diff --git a/web/browser-panel.js b/web/browser-panel.js index b579b534..a6769284 100644 --- a/web/browser-panel.js +++ b/web/browser-panel.js @@ -298,7 +298,7 @@ function Screen({ frame, interactive, send }) { return html`The page`; } @@ -444,7 +444,7 @@ function Screen({ frame, interactive, send }) { ref=${ref} class="browser-screen live" src=${frame.src} - alt="The page. Click to use it; once focused, keys go to the page." + alt=${t("The page. Click to use it; once focused, keys go to the page.")} tabindex="0" draggable="false" onPointerDown=${down} @@ -568,7 +568,7 @@ function ConsolePane({ conversationId, logs, steer, onClose }) {
${t("Console")} - ${entries ? `${entries.filter((entry) => entry.level !== "nav").length} lines` : ""} + ${entries ? t("{{count}} lines", { count: entries.filter((entry) => entry.level !== "nav").length }) : ""} ${ @@ -808,7 +808,7 @@ export function BrowserPanel({ leaving = false }) { stage = html`
<${Icon} name="globe" size=${28} />

- No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set PI_POCKET_BROWSER to a browser's path), then restart Pi Pocket. + ${t("No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set PI_POCKET_BROWSER to a browser's path), then restart Pi Pocket.")}

`; } else if (!open && why) { @@ -896,7 +896,7 @@ export function BrowserPanel({ leaving = false }) {
` } ${ diff --git a/web/commands.js b/web/commands.js index d8d2e3f7..7e68695f 100644 --- a/web/commands.js +++ b/web/commands.js @@ -159,8 +159,8 @@ async function setThinking(arg) { if (!levels.includes(level)) { throw new Error( levels.length > 1 - ? `Thinking can be ${levels.join(", ")}.` - : `${modelLabel(agent())} does not think.`, + ? t("Thinking can be {{levels}}.", { levels: levels.join(", ") }) + : t("{{model}} does not think.", { model: modelLabel(agent()) }), ); } @@ -210,7 +210,10 @@ function showSession() { if (window) { parts.push( - `context ${Math.round(((stats.contextTokens ?? 0) / window) * 100)}% of ${formatTokens(window)}`, + t("context {{percent}}% of {{tokens}}", { + percent: Math.round(((stats.contextTokens ?? 0) / window) * 100), + tokens: formatTokens(window), + }), ); } @@ -317,8 +320,8 @@ const COMMANDS = [ notify( "info", lesson.pending - ? "Remembered. Pi uses it once the owner approves it." - : "Remembered. Pi gets it from its next message.", + ? t("Remembered. Pi uses it once the owner approves it.") + : t("Remembered. Pi gets it from its next message."), ); }, }, @@ -329,7 +332,7 @@ const COMMANDS = [ available: goalsAvailable, run: async (arg) => { if (arg === "") { - throw new Error("Say which command has to pass, such as /until npm test."); + throw new Error(t("Say which command has to pass, such as /until npm test.")); } await actions.setGoal(arg); @@ -456,7 +459,10 @@ function setTheme(arg) { if (!id) { throw new Error( - `No theme called “${arg}”. Try ${Object.keys(THEMES).slice(0, 4).join(", ")}, or desktop.`, + t("No theme called “{{name}}”. Try {{themes}}, or desktop.", { + name: arg, + themes: Object.keys(THEMES).slice(0, 4).join(", "), + }), ); } diff --git a/web/composer.js b/web/composer.js index 43e137ec..031e25aa 100644 --- a/web/composer.js +++ b/web/composer.js @@ -145,7 +145,7 @@ function DriverBar() { return null; } - const name = (id) => users.find((user) => user.id === id)?.name ?? "someone"; + const name = (id) => users.find((user) => user.id === id)?.name ?? t("someone"); const driving = turns.driver === me?.id; const driverHere = turns.driver !== undefined && presence.some((person) => person.id === turns.driver); @@ -158,10 +158,10 @@ function DriverBar() { ); return html`
- 🚗 You're driving + 🚗 ${t("You're driving")} ${turns.asks.map( (id) => html` - ${name(id)} asks to drive + ${t("{{name}} asks to drive", { name: name(id) })} `, )} @@ -193,14 +193,14 @@ function DriverBar() { return html`
- ${turns.driver === undefined ? "🚗 No one is driving" : `🚗 ${name(turns.driver)} is driving${driverHere ? "" : " (away)"}`} + 🚗 ${turns.driver === undefined ? t("No one is driving") : t(driverHere ? "{{name}} is driving" : "{{name}} is driving (away)", { name: name(turns.driver) })} ${ turns.driver !== undefined && driverHere && html`` } ${ @@ -225,7 +225,7 @@ function PlanBar({ blocked }) { return html`
- ${t("Plan mode.")} ${subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve."} + ${t("Plan mode.")} ${t(subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve.")} ${ !blocked && @@ -255,17 +255,17 @@ function GoalBar() { const command = html`${goal.command}`; const text = goal.status === "met" - ? html`Done: ${command} passes (check ${goal.tries} of ${goal.max}).` + ? html`${t("Done:")} ${command} ${t("passes (check {{tries}} of {{max}}).", { tries: goal.tries, max: goal.max })}` : goal.status === "gave-up" - ? html`Stopped: ${command} still fails after ${goal.max} checks.` - : html`Until ${command} passes${goal.tries > 0 ? ` · check ${goal.tries} of ${goal.max} failed` : ""}.`; + ? html`${t("Stopped:")} ${command} ${t("still fails after {{max}} checks.", { max: goal.max })}` + : html`${t("Until")} ${command} ${t("passes")}${goal.tries > 0 ? ` · ${t("check {{tries}} of {{max}} failed", { tries: goal.tries, max: goal.max })}` : ""}.`; return html`
${text} ${ canSteer() && html`` }
`; @@ -281,15 +281,17 @@ const WATCH_CHECKS = { function watchEnded(watch) { switch (watch.status) { case "merged": - return "was merged."; + return t("was merged."); case "closed": - return "was closed."; + return t("was closed."); case "gave-up": - return `had more to tell than ${watch.max} messages: watch stopped.`; + return t("had more to tell than {{max}} messages: watch stopped.", { max: watch.max }); case "expired": - return "had nothing new for two weeks: watch stopped."; + return t("had nothing new for two weeks: watch stopped."); default: - return `could not be watched: ${watch.note ?? "something went wrong"}.`; + return t("could not be watched: {{error}}.", { + error: watch.note ?? t("something went wrong"), + }); } } @@ -305,7 +307,7 @@ function WatchBar() { const checks = WATCH_CHECKS[watch.checks]; const text = watch.status === "watching" - ? html`Watching ${pr}${checks ? ` · ${checks}` : ""}${watch.sent > 0 ? ` · ${watch.sent} of ${watch.max} sent` : ""}${watch.unchecked ? " · owner's comments only" : ""}${watch.errors > 0 ? " · last look failed" : watch.note ? " · waiting" : ""}` + ? html`${t("Watching")} ${pr}${checks ? ` · ${t(checks)}` : ""}${watch.sent > 0 ? ` · ${t("{{sent}} of {{max}} sent", { sent: watch.sent, max: watch.max })}` : ""}${watch.unchecked ? ` · ${t("owner's comments only")}` : ""}${watch.errors > 0 ? ` · ${t("last look failed")}` : watch.note ? ` · ${t("waiting")}` : ""}` : html`${pr} ${watchEnded(watch)}`; return html`
user.id === item.by)?.name ?? "someone"}`; + ? ` · ${t("you")}` + : ` · ${users.find((user) => user.id === item.by)?.name ?? t("someone")}`; useDock(dock); // View only now, or someone else took the wheel: the box goes, and dictation with it. @@ -968,7 +972,7 @@ export function Composer() { // How many wait: shown when the queue does not show them all (`dock.js`). inbox.length > 0 && html`
- ${inbox.length} ${inbox.length === 1 ? "message" : "messages"} waiting for Pi + ${t(inbox.length === 1 ? "{{count}} message waiting for Pi" : "{{count}} messages waiting for Pi", { count: inbox.length })}
` } ${ @@ -1023,7 +1027,7 @@ export function Composer() { ref=${list} >
- ${t("Sent before")}${text.trim() ? ` with “${text.trim()}”` : ""} · Enter puts it in the box · Esc closes + ${t("Sent before")}${text.trim() ? t(" with “{{query}}”", { query: text.trim() }) : ""} · ${t("Enter puts it in the box · Esc closes")}
${recalled.map( (each) => html`
` }
` @@ -1052,14 +1056,14 @@ export function Composer() { ${ shell && html`
- ${shell.context ? "!" : "!!"} · runs in this session's folder · ${shell.context ? "Pi sees the output (!! to keep it from Pi)" : "only the people here see the output"} + ${shell.context ? "!" : "!!"} · ${t("runs in this session's folder")} · ${t(shell.context ? "Pi sees the output (!! to keep it from Pi)" : "only the people here see the output")}
` } ${ pasted && !shell && html`
- Pasted text goes out in full where its placeholder is. + ${t("Pasted text goes out in full where its placeholder is.")}
` } ${ @@ -1119,7 +1123,7 @@ export function Composer() { ${ found.truncated && html`
- A large folder: only the files nearest its top are listed. + ${t("A large folder: only the files nearest its top are listed.")}
` }
` @@ -1138,9 +1142,9 @@ export function Composer() { /${command.name} ${ command.args && - html`${command.args}` + html`${command.template ? command.args : t(command.args)}` } - ${command.description} + ${command.template ? command.description : t(command.description)} `, )}
` @@ -1152,8 +1156,8 @@ export function Composer() { /${parsed.command.name} ${ parsed.command.args && - html` ${parsed.command.args}` - } · ${parsed.command.description} · not sent to Pi + html` ${parsed.command.template ? parsed.command.args : t(parsed.command.args)}` + } · ${parsed.command.template ? parsed.command.description : t(parsed.command.description)} · ${t("not sent to Pi")} ` } ${ @@ -1252,13 +1256,13 @@ export function Composer() { /> ${ planAvailable() && @@ -1394,7 +1398,7 @@ function StatusLine() { if (connection !== "open") { parts.push( html` - ${connection === "connecting" ? "connecting…" : "reconnecting…"} + ${t(connection === "connecting" ? "connecting…" : "reconnecting…")} `, ); } @@ -1404,9 +1408,9 @@ function StatusLine() { parts.push( html` here ? ` · ${view.clients} tabs` : ""}`} + title=${`${(view.viewers ?? []).join(", ")}${view.clients > here ? ` · ${t("{{count}} tabs", { count: view.clients })}` : ""}`} > - ${here} here + ${t("{{count}} here", { count: here })} `, ); @@ -1415,11 +1419,14 @@ function StatusLine() { const percent = Math.min(100, Math.round((used / window) * 100)); // Compacted since the last answer: what is left, estimated until the next answer measures it. const about = stats.contextEstimated ? "~" : ""; - const title = `${about}${formatTokens(used)} of ${formatTokens(window)} tokens in context`; + const title = t("{{used}} of {{total}} tokens in context", { + used: `${about}${formatTokens(used)}`, + total: formatTokens(window), + }); parts.push( html` - context + ${t("context")} @@ -1429,7 +1436,9 @@ function StatusLine() { } if (stats.cacheRate !== undefined) { - parts.push(html`cache ${Math.round(stats.cacheRate * 100)}%`); + parts.push( + html`${t("cache {{percent}}%", { percent: Math.round(stats.cacheRate * 100) })}`, + ); } // The session's spend, its subagents' included, and its limit when it has one. @@ -1454,7 +1463,7 @@ function StatusLine() { onClick=${() => openSheet({ type: "extensions" })} > <${Icon} name="shield" size=${11} /> - ${failed ? "guard failed" : guard.enabled ? "guard on" : "guard off"} + ${t(failed ? "guard failed" : guard.enabled ? "guard on" : "guard off")} `, ); } diff --git a/web/diff.js b/web/diff.js index b5961c51..541ef301 100644 --- a/web/diff.js +++ b/web/diff.js @@ -767,7 +767,7 @@ function DiffSettings() { aria-pressed=${wrap ? "true" : "false"} onClick=${() => setDiffPrefs({ wrap: !wrap })} > - Wrap + ${t("Wrap")} `; } @@ -1604,7 +1604,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false ${ changes.more > 0 && html`

- And ${changes.more} more changed ${changes.more === 1 ? "file" : "files"}, not listed here. + ${t(changes.more === 1 ? "And {{count}} more changed file, not listed here." : "And {{count}} more changed files, not listed here.", { count: changes.more })}

` } diff --git a/web/home.js b/web/home.js index 1937a1dc..bfa6970c 100644 --- a/web/home.js +++ b/web/home.js @@ -1,5 +1,6 @@ // The wide home screen, shown beside the session list when no session is open. +import { t } from "./i18n.js"; import { workspaceOrder } from "./sessions.js"; import { canSteer, navigate, openSheet, scoped, store } from "./store.js"; import { paletteOf, prefs } from "./theme.js"; @@ -24,14 +25,20 @@ export function Splash() { const busy = sessions.filter((session) => session.busy).length; const recent = workspaceOrder().slice(0, 5); const rows = [ - ["theme", `${palette.name}${p.theme === "desktop" && palette.desktop ? " (desktop)" : ""}`], + [ + "theme", + `${palette.name}${p.theme === "desktop" && palette.desktop ? ` (${t("desktop")})` : ""}`, + ], [ "layout", - `${p.tiling ? "tiled windows" : "flat"}, sidebar ${p.sidebar === "rail" ? "folded" : "open"}`, + t("{{layout}}, sidebar {{sidebar}}", { + layout: t(p.tiling ? "tiled windows" : "flat"), + sidebar: t(p.sidebar === "rail" ? "folded" : "open"), + }), ], [ "sessions", - `${sessions.filter((session) => !session.archived).length}${busy > 0 ? `, ${busy} running` : ""}`, + `${sessions.filter((session) => !session.archived).length}${busy > 0 ? `, ${t("{{count}} running", { count: busy })}` : ""}`, ], ["models", String(models.length)], ["folder", shortPath(server?.defaultCwd, server?.home) || "~"], @@ -39,11 +46,11 @@ export function Splash() { "guard", guard?.enabled ? guard.available === false - ? "failed to load" - : "Lancet Guard on" - : "off", + ? t("failed to load") + : t("Lancet Guard on") + : t("off"), ], - ["you", `${me?.name ?? "?"} (${me?.role ?? "?"})`], + ["you", `${me?.name ?? "?"} (${me?.role ? t(me.role) : "?"})`], ]; const colors = [ "--o-red", @@ -68,7 +75,7 @@ export function Splash() {
${"─".repeat(30)}
${rows.map( ([key, value]) => html`
-
${key}
+
${t(key)}
${value}
`, )} @@ -84,28 +91,28 @@ export function Splash() { class="button primary" onClick=${() => openSheet({ type: "cwd", mode: "new" })} > - <${Icon} name="plus" size=${16} /> New session <${Keys} keys="Alt N" /> + <${Icon} name="plus" size=${16} /> ${t("New session")} <${Keys} keys="Alt N" /> ` } ${ recent.length > 0 && html`
-
Jump back in
+
${t("Jump back in")}
${recent.map( ( session, index, ) => html``, diff --git a/web/launcher.js b/web/launcher.js index edaf688c..37f4fe3d 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -97,14 +97,14 @@ function actionItems() { run: () => openSheet({ type: "cwd", mode: "new" }), }, piSessionsAvailable() && { - label: "Continue a Pi session", - detail: "from Pi in the terminal", + label: t("Continue a Pi session"), + detail: t("from Pi in the terminal"), icon: "terminal", run: () => openSheet({ type: "pi-sessions" }), }, conversationId !== null && { label: t("All sessions"), - detail: "home", + detail: t("home"), icon: "home", run: () => navigate(null), }, @@ -121,8 +121,8 @@ function actionItems() { run: () => openSheet({ type: "appearance" }), }, wide && { - label: prefs().tiling ? "Turn tiling off" : "Turn tiling on", - detail: "gaps and window borders", + label: t(prefs().tiling ? "Turn tiling off" : "Turn tiling on"), + detail: t("gaps and window borders"), icon: "swatch", run: () => setPrefs({ tiling: !prefs().tiling }), }, @@ -141,15 +141,15 @@ function actionItems() { }, store.state.me?.role === "owner" && store.state.server?.defaultModel !== undefined && { - label: "Default model", - detail: "the model and thinking new sessions start with", + label: t("Default model"), + detail: t("the model and thinking new sessions start with"), icon: "sparkle", run: () => openSheet({ type: "model", id: "default", forDefault: true }), }, conversation && browserAvailable() && { label: store.state.browserOpen ? t("Close the browser") : t("Open the browser"), - detail: "see and test pages with Pi", + detail: t("see and test pages with Pi"), icon: "globe", keys: "Alt B", run: toggleBrowser, @@ -165,7 +165,7 @@ function actionItems() { store.state.filesOpen && store.state.filesTab === "files" ? t("Close the files") : t("Files"), - detail: "browse the folder, read files", + detail: t("browse the folder, read files"), icon: "folder", keys: "Alt E", run: () => toggleFiles("files"), @@ -173,14 +173,14 @@ function actionItems() { conversation && filesAvailable() && { label: t("Changes"), - detail: "review what changed, file by file", + detail: t("review what changed, file by file"), icon: "fork", run: () => toggleFiles("changes"), }, conversation && branchAvailable() && { label: t("Switch branch"), - detail: `on ${headLabel(view.branch)}`, + detail: t("on {{branch}}", { branch: headLabel(view.branch) }), icon: "fork", run: () => openSheet({ type: "branch" }), }, @@ -199,8 +199,8 @@ function actionItems() { session && steer && planAvailable() && { - label: view.plan?.on ? "Turn plan mode off" : "Turn plan mode on", - detail: "Pi proposes, changes nothing", + label: t(view.plan?.on ? "Turn plan mode off" : "Turn plan mode on"), + detail: t("Pi proposes, changes nothing"), icon: "shield", run: () => attempt(() => actions.setPlan(!view.plan?.on)), }, @@ -221,7 +221,7 @@ function actionItems() { }, session && steer && { - label: conversation.archived ? "Unarchive session" : "Archive session", + label: t(conversation.archived ? "Unarchive session" : "Archive session"), icon: "archive", run: () => setArchived([conversation.id], !conversation.archived), }, diff --git a/web/notify.js b/web/notify.js index d36edfba..aea3cb71 100644 --- a/web/notify.js +++ b/web/notify.js @@ -131,13 +131,13 @@ export function NotificationsSheet() { try { if ((await Notification.requestPermission()) !== "granted") { - throw new Error("Notifications were not allowed."); + throw new Error(t("Notifications were not allowed.")); } const registration = await registerWorker(); if (!registration) { - throw new Error("The service worker did not start."); + throw new Error(t("The service worker did not start.")); } await navigator.serviceWorker.ready; diff --git a/web/peeks.js b/web/peeks.js index be09266a..d78f7935 100644 --- a/web/peeks.js +++ b/web/peeks.js @@ -553,14 +553,14 @@ function PeekApproval({ approval, me, rule, whole }) { disabled=${busy} onClick=${() => answer(true)} > - Allow + ${t("Allow")} ` : html`` } `; @@ -610,7 +610,7 @@ class PeekTile extends Component { } render({ session, peek, status, label, number, compact, steer, home, me, rule }) { - const title = session.title ?? "New session"; + const title = session.title ?? t("New session"); const open = () => navigate(session.id); // A call answered elsewhere can stay on a tile scrolled away: the session list says whether one still waits. const approval = session.waiting ? peek?.approvals?.[0] : undefined; @@ -712,7 +712,7 @@ function tileProps(tiles, compact) { session, peek: state.peeks?.[session.id], status, - label, + label: t(label), number: order.get(session.id), compact, steer, diff --git a/web/pip.js b/web/pip.js index bc88d87d..a9da55a8 100644 --- a/web/pip.js +++ b/web/pip.js @@ -3,6 +3,7 @@ // while PiP works, or has something new. The panel watches PiP's conversation through a view of its own (`pipview` // events), so the session behind it stays as it was. import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { t } from "./i18n.js"; import { useBack } from "./back.js"; import { useDragToClose } from "./gestures.js"; import { api, attempt, isRow, navigate, notify, store, uid } from "./store.js"; @@ -187,7 +188,7 @@ export async function askPip(text) { const pip = store.state.pip ?? (await opening); if (!pip) { - notify("error", "PiP is not ready yet: your message waits in its box."); + notify("error", t("PiP is not ready yet: your message waits in its box.")); } // Not sent: the words wait in PiP's box rather than be lost (`send` said why), after what was there already. @@ -241,17 +242,20 @@ function PipContext({ stats, window }) { const percent = Math.min(100, Math.round((used / window) * 100)); const at = store.state.server?.pipCompactAt; const about = stats.contextEstimated ? "~" : ""; - const tokens = `${about}${formatTokens(used)} of ${formatTokens(window)} tokens in PiP's context.`; + const tokens = t("{{used}} of {{total}} tokens in PiP's context.", { + used: `${about}${formatTokens(used)}`, + total: formatTokens(window), + }); const when = at > 0 - ? `PiP compacts itself above ${formatTokens(at)} once idle.` - : "Compact it when it grows."; + ? t("PiP compacts itself above {{tokens}} once idle.", { tokens: formatTokens(at) }) + : t("Compact it when it grows."); const title = `${tokens} ${when}`; return html` @@ -382,10 +386,10 @@ function Item({ item, last, busy }) { return html`
${text.trim() !== "" && html`<${Markdown} text=${linkSessions(text)} cache=${!growing} />`} - ${entry?.stopReason === "aborted" && html`
Stopped
`} + ${entry?.stopReason === "aborted" && html`
${t("Stopped")}
`} ${ entry?.stopReason === "error" && - html`
${entry.error ?? "The model request failed."}
` + html`
${entry.error ?? t("The model request failed.")}
` }
`; } @@ -397,8 +401,8 @@ function Welcome({ id, name }) { const first = (name ?? "").split(/\s+/)[0]; return html`
-

Hi${first ? ` ${first}` : ""}. I’m PiP.

-

${INTRO}

+

${first ? t("Hi {{name}}. I’m PiP.", { name: first }) : t("Hi. I’m PiP.")}

+

${t(INTRO)}

${SUGGESTIONS.map( (suggestion) => html` -
@@ -679,7 +690,7 @@ function PipPanel({ leaving, scrim }) { items.at(-1)?.type !== "activity" && html`
<${Spinner} hidden=${true} /> - Thinking + ${t("Thinking")}
` } @@ -687,7 +698,7 @@ function PipPanel({ leaving, scrim }) { waiting.length > 0 && html`
<${Icon} name="shield" size=${14} /> - PiP waits for your approval: ${waiting[0].tool} + ${t("PiP waits for your approval: {{tool}}", { tool: waiting[0].tool })} ` } diff --git a/web/sessions.js b/web/sessions.js index d87a384e..57e9b60a 100644 --- a/web/sessions.js +++ b/web/sessions.js @@ -60,7 +60,7 @@ export async function setArchived(ids, archived, { undo = true } = {}) { } const titles = new Map( - store.state.sessions.map((session) => [session.id, session.title ?? "New session"]), + store.state.sessions.map((session) => [session.id, session.title ?? t("New session")]), ); markMoving(ids, archived); @@ -266,7 +266,7 @@ function SessionRow({ const open = session.id === conversationId && !session.archived; const unread = collab() && sessionUnread(session) && !open; const moving = store.state.moving[session.id] !== undefined; - const title = session.title ?? "New session"; + const title = session.title ?? t("New session"); const path = shortPath(session.cwd, server?.home); const where = [ session.worktree ? `${path} ⎇ ${session.worktree.branch}` : path, @@ -869,7 +869,7 @@ export function SessionList({ compact = false }) { canStart && html`` : html` { event.preventDefault(); store.set({ launcher: true, drawer: false }); @@ -914,7 +914,7 @@ export function SessionList({ compact = false }) { - `; diff --git a/web/sheets/hooks.js b/web/sheets/hooks.js index d1b95d97..1308cc5f 100644 --- a/web/sheets/hooks.js +++ b/web/sheets/hooks.js @@ -1,6 +1,7 @@ // Webhooks and watches: addresses other services post to, each turning every post into a message to Pi in a session or // to PiP; and the repository watches PiP set up, which need no address. import { useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { pipAvailable } from "../pip.js"; import { api, attempt, closeSheet, navigate, notify, store } from "../store.js"; import { copyText, formatWhen, html, Sheet } from "../ui.js"; @@ -11,7 +12,7 @@ const addressOf = (base, hook) => `${(base ?? location.origin).replace(/\/$/, "" function copy(text, what) { copyText(text).then( () => notify("info", what), - () => notify("error", "Could not copy."), + () => notify("error", t("Could not copy.")), ); } @@ -28,41 +29,46 @@ function HookRow({ hook, base, fresh, onRemove, onRotate }) { const used = hook.count === 0 - ? "no posts yet" - : `${hook.count} post${hook.count === 1 ? "" : "s"}, last ${formatWhen(hook.lastAt)}`; + ? t("no posts yet") + : t( + hook.count === 1 + ? "{{count}} post, last {{when}}" + : "{{count}} posts, last {{when}}", + { count: hook.count, when: formatWhen(hook.lastAt) }, + ); return html`
${hook.name} - to ${hook.pip ? "PiP" : html`${hook.targetTitle}`} + ${t("to")} ${hook.pip ? "PiP" : html`${hook.targetTitle}`}
${hook.instructions && html`
${hook.instructions}
`} ${ fresh && html`
${addressOf(base, hook)}
- ${hook.signed && html`
Secret: ${hook.secret}
`}` + ${hook.signed && html`
${t("Secret:")} ${hook.secret}
`}` }
- ${hook.signed ? "GitHub, signed" : "secret in the address"}${hook.anyone ? " · from anyone" : ""} · ${used} + ${t(hook.signed ? "GitHub, signed" : "secret in the address")}${hook.anyone ? ` · ${t("from anyone")}` : ""} · ${used} ${ hook.signed && html`` } - - + +
`; } @@ -79,29 +85,29 @@ function WatchRow({ watch, onRemove, onAnyone }) { }; const narrowed = [ - watch.label && `label ${watch.label}`, - watch.assigned && "assigned to you", - watch.events.includes("ci") && `CI on ${watch.branch}`, + watch.label && t("label {{label}}", { label: watch.label }), + watch.assigned && t("assigned to you"), + watch.events.includes("ci") && t("CI on {{branch}}", { branch: watch.branch }), ].filter(Boolean); return html`
${watch.repo} - to ${watch.pip ? "PiP" : html`${watch.targetTitle}`} + ${t("to")} ${watch.pip ? "PiP" : html`${watch.targetTitle}`}
${watch.events.join(", ")}${narrowed.length > 0 ? ` · ${narrowed.join(", ")}` : ""}
${watch.instructions && html`
${watch.instructions}
`}
- ${watch.count === 0 ? "nothing sent yet" : `${watch.count} sent, last ${formatWhen(watch.lastNewsAt)}`}${watch.note ? ` · ${watch.errors > 0 ? "last look failed" : "waiting"}: ${watch.note}` : ""} + ${watch.count === 0 ? t("nothing sent yet") : t("{{count}} sent, last {{when}}", { count: watch.count, when: formatWhen(watch.lastNewsAt) })}${watch.note ? ` · ${t(watch.errors > 0 ? "last look failed" : "waiting")}: ${watch.note}` : ""} - +
`; } @@ -160,7 +166,9 @@ export function HooksSheet() { attempt(async () => { if ( !confirm( - `Remove the webhook “${hook.name}”? Posts to its address stop reaching Pi.`, + t("Remove the webhook “{{name}}”? Posts to its address stop reaching Pi.", { + name: hook.name, + }), ) ) { return; @@ -172,12 +180,15 @@ export function HooksSheet() { const rotate = (hook) => attempt(async () => { const where = hook.signed - ? "Paste the new one into GitHub's Secret for it." - : "Give the new address to whatever posts to it."; + ? t("Paste the new one into GitHub's Secret for it.") + : t("Give the new address to whatever posts to it."); if ( !confirm( - `A new secret for “${hook.name}”: posts with the old one stop getting in. ${where}`, + t( + "A new secret for “{{name}}”: posts with the old one stop getting in. {{where}}", + { name: hook.name, where }, + ), ) ) { return; @@ -190,7 +201,11 @@ export function HooksSheet() { const unwatch = (watch) => attempt(async () => { - if (!confirm(`Stop watching ${watch.repo}? Nothing more comes from it.`)) { + if ( + !confirm( + t("Stop watching {{repo}}? Nothing more comes from it.", { repo: watch.repo }), + ) + ) { return; } @@ -205,7 +220,10 @@ export function HooksSheet() { if ( on && !confirm( - `Let anyone's issues and PRs on ${watch.repo} reach ${watch.pip ? "PiP" : "Pi"}? Strangers' words then come with your instructions.`, + t( + "Let anyone's issues and PRs on {{repo}} reach {{target}}? Strangers' words then come with your instructions.", + { repo: watch.repo, target: watch.pip ? "PiP" : "Pi" }, + ), ) ) { return; @@ -215,34 +233,34 @@ export function HooksSheet() { }).finally(load); }; - return html`<${Sheet} title="Webhooks and watches" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Webhooks and watches")} onClose=${closeSheet}>

- Other services (GitHub, a deploy, a monitor) post to a webhook's address, and each post becomes a message to Pi in a session, or to PiP: your instructions, then what came. + ${t("Other services (GitHub, a deploy, a monitor) post to a webhook's address, and each post becomes a message to Pi in a session, or to PiP: your instructions, then what came.")}

${ steer && targets.length > 0 && html`
-
Name
+
${t("Name")}
setName(event.currentTarget.value)} />
-
What Pi should do with each post
+
${t("What Pi should do with each post")}
-
Posted by
+
${t("Posted by")}
- ${github ? "Signed: GitHub gets the address and, separately, a secret; the address alone lets nothing in." : "The secret is in the address: anyone who has it can message Pi as you."} + ${t(github ? "Signed: GitHub gets the address and, separately, a secret; the address alone lets nothing in." : "The secret is in the address: anyone who has it can message Pi as you.")}
${ @@ -263,28 +281,28 @@ export function HooksSheet() { checked=${anyone} onChange=${(event) => setAnyone(event.currentTarget.checked)} /> - From anyone: issues, PRs, and comments by people outside the repository too + ${t("From anyone: issues, PRs, and comments by people outside the repository too")}
- ${anyone ? "Strangers' words reach Pi too, fenced as data." : "Off: posts about issues, PRs, and comments by people who cannot write to the repository are dropped (a 202 says so), and their words in other posts are left out. GitHub is asked who can write: gh must be able to push there."} + ${t(anyone ? "Strangers' words reach Pi too, fenced as data." : "Off: posts about issues, PRs, and comments by people who cannot write to the repository are dropped (a 202 says so), and their words in other posts are left out. GitHub is asked who can write: gh must be able to push there.")}
` } ${ targets.length > 1 && html`
-
Sends to
+
${t("Sends to")}
${targets.map( ([value, label]) => html``, )}
- ${target === "pip" ? "PiP decides what to do: start a session, message one, or tell you." : `Pi in “${here?.title}” gets each post.`} + ${target === "pip" ? t("PiP decides what to do: start a session, message one, or tell you.") : t("Pi in “{{title}}” gets each post.", { title: here?.title })}
` } @@ -293,10 +311,10 @@ export function HooksSheet() { disabled=${name.trim() === "" || making} onClick=${make} > - Make webhook + ${t("Make webhook")} ` } - ${list === null && html`

Loading…

`} + ${list === null && html`

${t("Loading…")}

`} ${ list?.repoWatches?.length > 0 && html`
@@ -327,10 +345,10 @@ export function HooksSheet() { ${ list && html`

- Ask PiP to watch a repository, such as “watch acme/api: when CI fails on main, fix it”. It needs no address. + ${t("Ask PiP to watch a repository, such as “watch acme/api: when CI fails on main, fix it”. It needs no address.")}

- ${list.base ? "" : `Services post to ${location.origin}, so they must be able to reach it: a tunnel's address works for GitHub. `}For GitHub: the repository's Settings → Webhooks → Add webhook; paste the address as the Payload URL and the secret as the Secret, choose application/json, and pick the events. + ${list.base ? "" : t("Services post to {{origin}}, so they must be able to reach it: a tunnel's address works for GitHub. ", { origin: location.origin })}${t("For GitHub: the repository's Settings → Webhooks → Add webhook; paste the address as the Payload URL and the secret as the Secret, choose application/json, and pick the events.")}

` } `; diff --git a/web/sheets/memory.js b/web/sheets/memory.js index 28a56997..c5ab19c2 100644 --- a/web/sheets/memory.js +++ b/web/sheets/memory.js @@ -1,5 +1,6 @@ // Memory: the lessons Pi keeps across sessions, for this session's project and for everywhere. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, closeSheet, navigate, notify, store } from "../store.js"; import { html, Sheet, timeAgo } from "../ui.js"; @@ -103,9 +104,9 @@ export function memoryHint(data) { ? data.lessons.filter((lesson) => lesson.pending || lesson.proposal).length : 0; const parts = [ - data.project ? `${here} here` : null, - `${everywhere} everywhere`, - waiting > 0 ? `${waiting} to approve` : null, + data.project ? t("{{count}} here", { count: here }) : null, + t("{{count}} everywhere", { count: everywhere }), + waiting > 0 ? t("{{count}} to approve", { count: waiting }) : null, ]; return parts.filter(Boolean).join(" · "); @@ -133,7 +134,7 @@ function approval(lesson) { /** Who saved a lesson, and where it came from, in a line. */ function Source({ lesson }) { const { me } = store.state; - const who = lesson.byPi ? "Pi" : lesson.by === me?.id ? "you" : lesson.byName; + const who = lesson.byPi ? "Pi" : lesson.by === me?.id ? t("you") : lesson.byName; const open = (event) => { event.preventDefault(); @@ -143,19 +144,19 @@ function Source({ lesson }) { return html`
${who} - ${lesson.byPi && lesson.by !== me?.id && html` for ${lesson.byName}`} + ${lesson.byPi && lesson.by !== me?.id && html` ${t("for {{name}}", { name: lesson.byName })}`} ${ lesson.from && html` · ${lesson.from.title}` } · ${timeAgo(lesson.updatedAt ?? lesson.createdAt)} - ${(lesson.pending || lesson.proposal) && html` · for ${reach(lesson)}`} + ${(lesson.pending || lesson.proposal) && html` · ${t("for {{scope}}", { scope: reach(lesson) })}`}
`; } /** Where a lesson applies, said where it waits: everywhere's reach every session and PiP. */ const reach = (lesson) => - lesson.scope === "everywhere" ? "everywhere: every session, and PiP" : "this project"; + t(lesson.scope === "everywhere" ? "everywhere: every session, and PiP" : "this project"); /** One lesson: its text, where it came from, and what this person may do with it. */ function Lesson({ lesson, data }) { @@ -191,13 +192,13 @@ function Lesson({ lesson, data }) { onInput=${(event) => setDraft(event.currentTarget.value)} >
- +
`; @@ -209,7 +210,7 @@ function Lesson({ lesson, data }) { lesson.proposal && html`
- ${lesson.proposal.byPi ? "Pi" : lesson.proposal.mine ? "You" : lesson.proposal.byName} ${lesson.proposal.forget ? "proposes forgetting it." : "proposes:"} + ${t(lesson.proposal.forget ? "{{name}} proposes forgetting it." : "{{name}} proposes:", { name: lesson.proposal.byPi ? "Pi" : lesson.proposal.mine ? t("You") : lesson.proposal.byName })} ${lesson.proposal.text && html`
${lesson.proposal.text}
`}
` @@ -232,7 +233,7 @@ function Lesson({ lesson, data }) { act( "/approve", approval(lesson), - "Approved: Pi goes by it from its next message.", + t("Approved: Pi goes by it from its next message."), )} > Approve @@ -241,7 +242,7 @@ function Lesson({ lesson, data }) { ${ (approving || withdrawing) && html`` } ${ @@ -250,7 +251,7 @@ function Lesson({ lesson, data }) { class="button small ghost" onClick=${() => setDraft(lesson.text)} > - Edit + ${t("Edit")} ` } ${ @@ -288,7 +289,7 @@ export function MemorySheet({ draft: initial = "" }) { if (!data) { return html`<${Sheet} title="Memory" onClose=${closeSheet}> -

Loading…

+

${t("Loading…")}

`; } @@ -312,22 +313,22 @@ export function MemorySheet({ draft: initial = "" }) { notify( "info", lesson.pending - ? "Saved. Pi uses it once the owner approves it." - : "Saved. Pi gets it from its next message.", + ? t("Saved. Pi uses it once the owner approves it.") + : t("Saved. Pi gets it from its next message."), ); }).finally(() => setBusy(false)); }; return html`<${Sheet} title="Memory" onClose=${closeSheet}>

- Short lessons Pi keeps across sessions: how to build and test, gotchas, conventions, preferences. ${project ? html`Those for ${project.name} go to every session in it, worktrees too; the rest go everywhere.` : "PiP keeps lessons for everywhere."} Pi proposes them as it learns, and the owner approves; yours count at once. + ${t("Short lessons Pi keeps across sessions: how to build and test, gotchas, conventions, preferences.")} ${project ? t("Those for {{project}} go to every session in it, worktrees too; the rest go everywhere.", { project: project.name }) : t("PiP keeps lessons for everywhere.")} ${t("Pi proposes them as it learns, and the owner approves; yours count at once.")}

${ data.canWrite && html`
- +
`; diff --git a/web/sheets/model.js b/web/sheets/model.js index bcaaf2d8..ced0fdf0 100644 --- a/web/sheets/model.js +++ b/web/sheets/model.js @@ -215,7 +215,7 @@ export function ModelPicker() { ${ models.length > 0 && shown.length === 0 && - html`

${t("No model matches “")}${query.trim()}”.

` + html`

${t("No model matches “{{query}}”.", { query: query.trim() })}

` } ${shown.map( (model, index) => html``, )}
diff --git a/web/sheets/people.js b/web/sheets/people.js index 3fb4baa3..d26ac565 100644 --- a/web/sheets/people.js +++ b/web/sheets/people.js @@ -107,7 +107,7 @@ export function InviteSheet({ session = null }) { if (asOwner && answer.grant?.role !== "owner") { setFailed(true); - throw new Error("Restart Pi Pocket to sign in another device as the owner."); + throw new Error(t("Restart Pi Pocket to sign in another device as the owner.")); } shown.current = answer; @@ -239,7 +239,7 @@ export function InviteSheet({ session = null }) {
${invite.code.slice(0, 5)} ${invite.code.slice(5)} @@ -269,7 +269,7 @@ export function InviteSheet({ session = null }) { ${ invite.alternatives?.length > 0 && html`

- Also reachable at: ${invite.alternatives.map( + ${t("Also reachable at:")} ${invite.alternatives.map( (url) => html`${url} `, )}

` diff --git a/web/sheets/pi-sessions.js b/web/sheets/pi-sessions.js index a2421760..633a1720 100644 --- a/web/sheets/pi-sessions.js +++ b/web/sheets/pi-sessions.js @@ -1,5 +1,6 @@ // Pi's sessions from the terminal, which the owner can continue here: the list, by folder, and one of them before it // continues, as a short conversation. +import { t } from "../i18n.js"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { actions, attempt, closeSheet, navigate, notify, openSheet, store } from "../store.js"; import { html, Icon, Loader, Sheet, shortPath, timeAgo } from "../ui.js"; @@ -7,7 +8,7 @@ import { html, Icon, Loader, Sheet, shortPath, timeAgo } from "../ui.js"; /** Only the owner: Pi's sessions are the owner's files. */ export const piSessionsAvailable = () => store.state.me?.role === "owner"; -const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`; +const messages = (count) => t(count === 1 ? "{{count}} message" : "{{count}} messages", { count }); /** A folder's last part, and the folder it is in: `pi-pocket`, `~/code`. */ function folderParts(cwd, home) { @@ -40,13 +41,13 @@ function SessionRow({ session }) { ${session.title} - ${plural(session.messages, "message")} · ${timeAgo(session.modified)} + ${messages(session.messages)} · ${timeAgo(session.modified)} ${ pocket && html` - ${pocket.behind ? "Pi went on" : "in Pocket"} + ${t(pocket.behind ? "Pi went on" : "in Pocket")} ` } <${Icon} name="chevron" size=${14} /> @@ -103,21 +104,21 @@ export function PiSessionsSheet() { } }, [any]); - return html`<${Sheet} title="Continue a Pi session" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Continue a Pi session")} onClose=${closeSheet}>

- Sessions of Pi in the terminal on this computer. One continues here as a new session; its file stays as it is. + ${t("Sessions of Pi in the terminal on this computer. One continues here as a new session; its file stays as it is.")}

${problem && html`
${problem}
`} - ${sessions === null && problem === null && html`<${Loader} label="Finding Pi's sessions" />`} - ${any === false && html`

Pi has no sessions on this computer yet.

`} + ${sessions === null && problem === null && html`<${Loader} label=${t("Finding Pi's sessions")} />`} + ${any === false && html`

${t("Pi has no sessions on this computer yet.")}

`} ${ any === true && html` setQuery(event.currentTarget.value)} />` @@ -145,7 +146,7 @@ export function PiSessionsSheet() { any === true && needle !== "" && sessions?.length === 0 && - html`

No session matches “${needle}”.

` + html`

${t("No session matches “{{query}}”.", { query: needle })}

` }
`; @@ -184,7 +185,9 @@ export function PiSessionSheet({ path }) { open(id); notify( "info", - "Continued from Pi. Pi has the conversation as Pi left it; nothing it did runs again.", + t( + "Continued from Pi. Pi has the conversation as Pi left it; nothing it did runs again.", + ), ); } finally { going.current = false; @@ -194,57 +197,57 @@ export function PiSessionSheet({ path }) { }; if (info === null) { - return html`<${Sheet} title="Pi session" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Pi session")} onClose=${closeSheet}> ${ problem === null - ? html`<${Loader} label="Reading the session" />` + ? html`<${Loader} label=${t("Reading the session")} />` : html`
${problem}
` } `; } - return html`<${Sheet} title="Pi session" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Pi session")} onClose=${closeSheet}>

${info.title}

-
Folder
+
${t("Folder")}
${shortPath(info.cwd, home)}
-
Model
+
${t("Model")}
- ${info.model ?? "—"}${info.model && !info.modelHere ? html` · not signed in here` : ""} + ${info.model ?? "—"}${info.model && !info.modelHere ? html` · ${t("not signed in here")}` : ""}
-
Messages
+
${t("Messages")}
${info.messages}
${ !info.cwdExists && html`
- The folder it worked in is not there anymore, so it cannot continue here. + ${t("The folder it worked in is not there anymore, so it cannot continue here.")}
` } ${ info.model && !info.modelHere && - html`

It continues with Pi Pocket's usual model.

` + html`

${t("It continues with Pi Pocket's usual model.")}

` } ${ info.pocket && html`
- ${info.pocket.behind ? "Continued here before Pi went on in the terminal." : "Already continued here."} + ${t(info.pocket.behind ? "Continued here before Pi went on in the terminal." : "Already continued here.")} - +
` } ${ info.last.length > 0 && - html`
-
Its last messages
+ html`
+
${t("Its last messages")}
${info.last.map((line) => line.role === "user" ? html`
${line.text}
` @@ -254,10 +257,10 @@ export function PiSessionSheet({ path }) { }

- Pi gets the conversation as Pi left it, with Pi Pocket's instructions and tools. Nothing Pi did runs again, and the session's file stays as it is. + ${t("Pi gets the conversation as Pi left it, with Pi Pocket's instructions and tools. Nothing Pi did runs again, and the session's file stays as it is.")}

`; diff --git a/web/sheets/running.js b/web/sheets/running.js index 0311b682..d89c231c 100644 --- a/web/sheets/running.js +++ b/web/sheets/running.js @@ -46,7 +46,7 @@ export function RunningSheet() { ${ session.approvals > 0 && html` - ${session.approvals} waiting for approval + ${t("{{count}} waiting for approval", { count: session.approvals })} ` } ${ diff --git a/web/sheets/viewers.js b/web/sheets/viewers.js index 168bcac2..8ff94aac 100644 --- a/web/sheets/viewers.js +++ b/web/sheets/viewers.js @@ -71,7 +71,7 @@ export function ArtifactViewer({ id, version }) {
${artifact?.title ?? id} - ${artifact?.type ?? ""} · version ${shown} + ${artifact?.type ?? ""} · ${t("version {{version}}", { version: shown })} ${latest && shown !== latest ? html` · ` : ""}
diff --git a/web/store.js b/web/store.js index 9fe7d4b4..1374cca1 100644 --- a/web/store.js +++ b/web/store.js @@ -308,11 +308,11 @@ export async function api(path, body, options = {}) { let nextNotice = 1; /** Show a short notice. With `action`, tapping it runs the action as well as dismissing it. */ -export function notify(level, message, action) { +export function notify(level, message, action, ui) { const id = nextNotice++; store.set((state) => ({ - notices: [...state.notices, { id, level, message, action }].slice(-4), + notices: [...state.notices, { id, level, message, action, ui }].slice(-4), })); setTimeout(() => dismiss(id), level === "error" ? 9000 : 4500); } @@ -468,6 +468,7 @@ const handlers = { sheet: data.link.sheet ? { type: data.link.sheet } : null, }) : undefined, + data.ui, ), auth: (data) => handleAuth(data), closing: () => store.set({ connection: "closed" }), @@ -733,7 +734,12 @@ function handleAuth(data) { if (data.step === "done") { if (data.ok) { - notify("info", t("Signed in to {{provider}}.", { provider: data.providerName ?? data.providerId })); + notify( + "info", + t("Signed in to {{provider}}.", { + provider: data.providerName ?? data.providerId, + }), + ); } return { auth: data.ok ? null : { ...flow, prompt: null, done: data } }; diff --git a/web/subagents-board.js b/web/subagents-board.js index aba81011..f2cf947c 100644 --- a/web/subagents-board.js +++ b/web/subagents-board.js @@ -4,6 +4,7 @@ // Open, Stop, and Dismiss. The arrows move between squares and Enter opens one. Phones get one column and a bar at the // bottom for the one selected. import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { t } from "./i18n.js"; import { folderColor } from "./avatar.js"; import { useBack } from "./back.js"; import { fromServer, useOnScreen } from "./peeks.js"; @@ -40,7 +41,7 @@ const needsYou = (agent) => boardState(agent) === "waiting" || unresolved(agent) /** A subagent's state in words: "Failed · dismissed" for a failure someone dealt with. */ const labelOf = (agent) => - `${LABELS[boardState(agent)]}${boardState(agent) === "failed" && agent.dealt ? " · dismissed" : ""}`; + `${t(LABELS[boardState(agent)])}${boardState(agent) === "failed" && agent.dealt ? " · dismissed" : ""}`; /** The board goes to one column below this width, as sheets come up from the bottom. */ const PHONE = matchMedia("(max-width: 699px)"); @@ -92,17 +93,17 @@ function summaryOf(counts, { short = false } = {}) { // Only finished ones left: those stopped, apart from those done. const finished = ["stopped", "done"] .filter((state) => counts[state] > 0) - .map((state) => `${counts[state]} ${state}`); + .map((state) => `${counts[state]} ${t(state)}`); return parts.length > 0 ? parts - .map((state) => `${counts[state]} ${state === "waiting" ? "approve" : state}`) + .map((state) => `${counts[state]} ${t(state === "waiting" ? "approve" : state)}`) .join(" · ") : finished.join(" · "); } return STATES.filter((state) => counts[state] > 0) - .map((state) => `${counts[state]} ${state === "waiting" ? "need approval" : state}`) + .map((state) => `${counts[state]} ${t(state === "waiting" ? "need approval" : state)}`) .join(" · "); } @@ -133,18 +134,21 @@ function ago(server) { const ms = fromServer(server); if (Date.now() - ms >= 30 * 86_400_000) { - return `on ${new Date(ms).toLocaleDateString()}`; + return t("on {{date}}", { date: new Date(ms).toLocaleDateString() }); } const when = timeAgo(ms); - return when === "now" ? "just now" : `${when} ago`; + return when === t("now") ? t("just now") : t("{{time}} ago", { time: when }); } /** What a subagent does now: the call waiting for an approval, or what its peek shows. */ function nowOf(agent) { if (agent.waiting && agent.approval) { - return `Waiting for approval: ${agent.approval.tool} ${agent.approval.subject}`.trim(); + return t("Waiting for approval: {{tool}} {{subject}}", { + tool: agent.approval.tool, + subject: agent.approval.subject, + }).trim(); } return nowDoing(agent); @@ -188,7 +192,7 @@ function Cell({ agent, state, dim, selected, onPick, onHover }) { return html` - ${now ? "Running" : "Ended"} + ${t(now ? "Running" : "Ended")} ${whenOf(agent) || "—"} ${ agent.asked && - html`Asked + html`${t("Asked")} ${agent.asked}` } ${ now && - html`Now + html`${t("Now")} ${nowOf(agent)}` } ${ state === "failed" && agent.error && - html`Error${agent.error}` + html`${t("Error")}${agent.error}` }
- ${agent.reporting && html`report on its way to Pi`} + ${agent.reporting && html`${t("report on its way to Pi")}`}
${ now && canSteer() && - html`` + html`` } <${Dismiss} agent=${agent} />
@@ -344,7 +348,7 @@ function StatusLine({ agent }) { ? html`<${Mark} state=${state} glyph=${false} /> ${agent.name} - ${live(state) ? whenOf(agent) : `${state} ${whenOf(agent)}`} + ${live(state) ? whenOf(agent) : `${t(state)} ${whenOf(agent)}`} ${lineOf(agent)} ` - : html`Hover a square to see what it is doing` + : html`${t("Hover a square to see what it is doing")}` } - ←→↑↓ move · Enter opens + ${t("←→↑↓ move · Enter opens")}
`; } @@ -369,7 +373,7 @@ function DetailBar({ agent, title, onClose }) { ${agent.name} ${labelOf(agent)} ${whenOf(agent)} - @@ -377,12 +381,12 @@ function DetailBar({ agent, title, onClose }) {
${lineOf(agent)}
${ live(state) && canSteer() && - html`` + html`` } <${Dismiss} agent=${agent} />
@@ -394,7 +398,7 @@ function Search({ query, setQuery }) { return html`