diff --git a/CHANGELOG.md b/CHANGELOG.md index acf0026..dc9b86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ All notable changes to LLeMbas Desktop. The format follows the account has another computer linked, a chip before the folder chooses it (this computer is the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal before the first message opens there too. +- **Language and theme in Settings.** Under Appearance: the language (this computer's, or any of + the app's nine; the window opens again in it) and the theme (the account's, or Moria or Shire). + Both are kept in the app on this computer; the account's own stay as they are. + +### Fixed + +- **A terminal that ended could come back by itself.** Shown again and ended by the server within + one frame, the panel opened a new shell in its place; an ended shell is now only said. ## [1.0.0-beta.6] — 2026-11-10 diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index db4d006..6d37155 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -458,6 +458,17 @@ KEYS = ( "Type a folder…", "Choose a folder first.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", + # settings.js: language and theme + "Appearance", + "Language", + "Theme", + "dark", + "light", + "The interface only. Models answer in the language you write to them in, whatever this says.", + "This computer's (%(language)s)", + "The account's (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.", + "Kept in this app on this computer; the account's theme stays as it is.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -695,6 +706,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Zadat složku…", "Choose a folder first.": "Nejprve vyberte složku.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.", + "This computer's (%(language)s)": "Podle tohoto počítače (%(language)s)", + "The account's (%(theme)s)": "Podle účtu (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.", + "Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.", }, "de": { "Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.", @@ -927,6 +942,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Ordner eingeben…", "Choose a folder first.": "Wählen Sie zuerst einen Ordner.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.", + "This computer's (%(language)s)": "Wie dieser Computer (%(language)s)", + "The account's (%(theme)s)": "Wie das Konto (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.", + "Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.", }, "es": { "Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.", @@ -1159,6 +1178,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Escribir una carpeta…", "Choose a folder first.": "Elija primero una carpeta.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.", + "This computer's (%(language)s)": "La de este equipo (%(language)s)", + "The account's (%(theme)s)": "El de la cuenta (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.", + "Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.", }, "fr": { "Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.", @@ -1391,6 +1414,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Saisir un dossier…", "Choose a folder first.": "Choisissez d’abord un dossier.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.", + "This computer's (%(language)s)": "Celle de cet ordinateur (%(language)s)", + "The account's (%(theme)s)": "Celui du compte (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.", + "Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.", }, "hu": { "Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.", @@ -1623,6 +1650,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Mappa megadása…", "Choose a folder first.": "Először válasszon mappát.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.", + "This computer's (%(language)s)": "A számítógépé (%(language)s)", + "The account's (%(theme)s)": "A fióké (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.", + "Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.", }, "it": { "Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.", @@ -1855,6 +1886,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Digita una cartella…", "Choose a folder first.": "Scegli prima una cartella.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.", + "This computer's (%(language)s)": "Quella di questo computer (%(language)s)", + "The account's (%(theme)s)": "Quello dell’account (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.", + "Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2087,6 +2122,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Wpisz folder…", "Choose a folder first.": "Najpierw wybierz folder.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.", + "This computer's (%(language)s)": "Jak na tym komputerze (%(language)s)", + "The account's (%(theme)s)": "Jak na koncie (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.", + "Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.", }, "sk": { "Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.", @@ -2319,6 +2358,10 @@ OWN: dict[str, dict[str, str]] = { "Type a folder…": "Zadať priečinok…", "Choose a folder first.": "Najprv vyberte priečinok.", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.", + "This computer's (%(language)s)": "Podľa tohto počítača (%(language)s)", + "The account's (%(theme)s)": "Podľa účtu (%(theme)s)", + "Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.", + "Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.", }, } @@ -2337,9 +2380,13 @@ def server_catalogue(server: Path, lang: str) -> dict[str, str]: messages = getattr(module, "MESSAGES", None) if not isinstance(messages, dict): sys.exit(f"gen-i18n: {path} has no MESSAGES") + NAMES[lang] = str(getattr(module, "NAME", "") or lang) return messages +NAMES: dict[str, str] = {} # each language's own name for itself, as the server's catalogue says + + def main() -> None: server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas")) catalogue: dict[str, dict[str, str]] = {} @@ -2372,24 +2419,38 @@ def main() -> None: print(f"gen-i18n: {lang}: {len(table)} sentences, {from_server} of the page's {len(KEYS)} from the server") body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True) + names = json.dumps({"en": "English", **NAMES}, ensure_ascii=False, sort_keys=True) out = ( "// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:\n" "// the sentences the server also has come from its web/i18n catalogues, the rest from the\n" "// script's own table. Do not edit; run the script.\n" "//\n" "// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n" - "// missing entry is the English. The language is the system's (navigator.language),\n" - "// English unless it is one of these. Elements marked `data-i18n` (their text) and\n" + "// missing entry is the English. The language is the one chosen in Settings (kept in\n" + "// localStorage as `lembas.language`), else the system's (navigator.language), English\n" + "// unless it is one of these. Elements marked `data-i18n` (their text) and\n" "// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n" "// are translated once the page is parsed; this script is deferred, before the page's.\n" '"use strict"\n' "\n" f"const LEMBAS_I18N = {body}\n" "\n" - "const LEMBAS_LANG = (() => {\n" + "// Each language's name for itself, for the choice in Settings.\n" + f"const LEMBAS_LANGS = {names}\n" + "\n" + "// What the system's language gives, and what was chosen here instead (\"\" for the system's).\n" + "const LEMBAS_SYSTEM_LANG = (() => {\n" ' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n' ' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n' "})()\n" + "const LEMBAS_CHOSEN_LANG = (() => {\n" + ' let code = ""\n' + " try {\n" + ' code = localStorage.getItem("lembas.language") || ""\n' + " } catch {}\n" + ' return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""\n' + "})()\n" + "const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n" "document.documentElement.lang = LEMBAS_LANG\n" "\n" "function t(text, fields) {\n" diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index b8c3751..765cbfc 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -994,6 +994,74 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th assert.deepEqual(p.errors, []) }) +// E5: the language and the theme, chosen in Settings and kept in this app. + +async function openSettings(p) { + await p.click("#settings-btn") + await p.waitForSelector("#set-look") +} + +test("E5: the theme is the account's until one is chosen here; chosen, it is drawn at once and kept", async () => { + const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} }) + await p.waitForFunction(() => document.documentElement.dataset.theme === "shire") + await openSettings(p) + assert.equal(await p.inputValue("#set-theme"), "") + assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/) + await p.selectOption("#set-theme", "moria") + assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") + const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name)) + assert.equal(painted.at(-1), "moria", "the window is painted in it too") + // Opened again: still Moria, over the account's Shire. + await p.reload() + await p.waitForSelector("#shell:not([hidden])") + await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme")) + assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") + await openSettings(p) + assert.equal(await p.inputValue("#set-theme"), "moria") + await p.selectOption("#set-theme", "") + assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire", "back to the account's") + assert.deepEqual(p.errors, []) +}) + +test("E5: a language chosen here opens the window again in it, the shells let go first; the system's again undoes it", async () => { + const p = await page(b3()) + assert.equal(await p.evaluate(() => document.documentElement.lang), "en") + await openChatRow(p, "chat2") + await p.click('#chat-tools [data-terminal-toggle]') + await p.waitForFunction(() => window.__mock.terminals().length === 1) + // What the page let go of before it loaded again, kept across the load. + await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd))))) + await openSettings(p) + assert.match(await p.textContent("#set-language option[value='']"), /English/) + await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "de")]) + await p.waitForSelector("#shell:not([hidden])") + assert.equal(await p.evaluate(() => document.documentElement.lang), "de") + assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen") + const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]")) + assert.ok(before.includes("terminal_close"), "the shell was let go") + await openSettings(p) + assert.equal(await p.inputValue("#set-language"), "de") + assert.match(await p.textContent("#set-look"), /Sprache/) + await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "")]) + await p.waitForSelector("#shell:not([hidden])") + assert.equal(await p.evaluate(() => document.documentElement.lang), "en") + assert.deepEqual(p.errors, []) +}) + +test("E5: at 390 px the Appearance group stays inside the window", async () => { + const p = await page({ status: status(), routes: baseRoutes(), streams: {} }, { width: 390, height: 844 }) + await p.evaluate(() => document.getElementById("settings-btn").click()) + await p.waitForSelector("#set-look") + await p.evaluate(() => document.getElementById("set-look").scrollIntoView()) + assert.ok(await noSideways(p, 390), "nothing scrolls sideways") + const box = await p.locator("#set-theme").boundingBox() + assert.ok(box.x >= 0 && box.x + box.width <= 390, "the theme's choice is inside") + await shot(p, "e5-look-390") + await p.setViewportSize({ width: 1280, height: 860 }) + await shot(p, "e5-look-1280") + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/i18n.js b/ui/i18n.js index c413b11..b4ca9a5 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -3,8 +3,9 @@ // script's own table. Do not edit; run the script. // // `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a -// missing entry is the English. The language is the system's (navigator.language), -// English unless it is one of these. Elements marked `data-i18n` (their text) and +// missing entry is the English. The language is the one chosen in Settings (kept in +// localStorage as `lembas.language`), else the system's (navigator.language), English +// unless it is one of these. Elements marked `data-i18n` (their text) and // `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute) // are translated once the page is parsed; this script is deferred, before the page's. "use strict" @@ -54,6 +55,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentní konverzace pracuje ve složce na tomto počítači přes agenta vestavěného v této aplikaci.", "Another device": "Jiné zařízení", "Answer": "Odpovědět", + "Appearance": "Vzhled", "Approve this code in the page that opened in your browser:": "Potvrďte tento kód na stránce, která se otevřela ve vašem prohlížeči:", "Archive chat": "Archivovat konverzaci", "Archived": "Archivované", @@ -143,8 +145,11 @@ const LEMBAS_I18N = { "Instruction files": "Soubory s pokyny", "It has not run yet.": "Ještě neběžel.", "It says: %(purpose)s": "Model uvádí: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.", + "Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.", "LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potřebuje server LLeMbas 2.0 nebo novější.", + "Language": "Jazyk", "Last run": "Poslední běh", "Let the server open a terminal here": "Dovolit serveru otevřít zde terminál", "Link again": "Propojit znovu", @@ -293,6 +298,7 @@ const LEMBAS_I18N = { "Tell me": "Upozornit mě", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde", "Terminal": "Terminál", + "The account's (%(theme)s)": "Podle účtu (%(theme)s)", "The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:", "The agent asks:": "Agent se ptá:", "The agent has finished.": "Agent dokončil práci.", @@ -310,6 +316,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "První volný model pro plánování", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta ke složce na tom počítači. Jeho agent otevře jen složky, které dostal.", "The instruction each run is given, on its own: say everything it needs.": "Instrukce, kterou dostane každé spuštění samostatně: uveďte vše, co potřebuje.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Jen rozhraní. Modely odpovídají v jazyce, ve kterém jim píšete, bez ohledu na toto nastavení.", "The last %(n)s runs failed: %(why)s": "Posledních %(n)s spuštění selhalo: %(why)s", "The model's own": "Podle modelu", "The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí", @@ -324,12 +331,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s", "The terminal could not be loaded.": "Terminál se nepodařilo načíst.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potřebuje novější server LLeMbas. Požádejte jeho správce o aktualizaci.", + "Theme": "Vzhled", "These words give no run in the future.": "Z těchto slov nevychází žádné budoucí spuštění.", "They run on the computer this chat runs on.": "Běží na počítači, na kterém běží tato konverzace.", "Think before answering": "Přemýšlet před odpovědí", "Thinking": "Přemýšlí", "This computer": "Tento počítač", "This computer is not linked yet.": "Tento počítač ještě není propojený.", + "This computer's (%(language)s)": "Podle tohoto počítače (%(language)s)", "This device is not linked right now.": "Toto zařízení teď není připojené.", "This file is too big to show here (%(size)s).": "Tento soubor je příliš velký na zobrazení (%(size)s).", "This file is too large to send.": "Tento soubor je příliš velký na odeslání.", @@ -385,6 +394,7 @@ const LEMBAS_I18N = { "busy": "zaneprázdněný", "by you": "vámi", "caught up": "dohnáno", + "dark": "tmavý", "default": "výchozí", "done": "hotovo", "failed": "selhalo", @@ -395,6 +405,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "vyřizuje jeden požadavek najednou a právě vyřizuje jiný", "its server holds another model now": "jeho server má teď načtený jiný model", "late": "se zpožděním", + "light": "světlý", "messages: %(n)s": "zprávy: %(n)s", "not linked": "nepropojeno", "off": "vypnuto", @@ -462,6 +473,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Ein Agenten-Chat arbeitet in einem Ordner auf diesem Computer, über den in diese App eingebauten Agenten.", "Another device": "Ein anderes Gerät", "Answer": "Antworten", + "Appearance": "Darstellung", "Approve this code in the page that opened in your browser:": "Bestätigen Sie diesen Code auf der Seite, die sich in Ihrem Browser geöffnet hat:", "Archive chat": "Chat archivieren", "Archived": "Archiviert", @@ -551,8 +563,11 @@ const LEMBAS_I18N = { "Instruction files": "Anweisungsdateien", "It has not run yet.": "Er ist noch nicht gelaufen.", "It says: %(purpose)s": "Es sagt: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.", + "Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop braucht einen LLeMbas-Server 2.0 oder neuer.", + "Language": "Sprache", "Last run": "Letzter Lauf", "Let the server open a terminal here": "Dem Server erlauben, hier ein Terminal zu öffnen", "Link again": "Erneut verbinden", @@ -701,6 +716,7 @@ const LEMBAS_I18N = { "Tell me": "Benachrichtigen", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin", "Terminal": "Terminal", + "The account's (%(theme)s)": "Wie das Konto (%(theme)s)", "The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:", "The agent asks:": "Der Agent fragt:", "The agent has finished.": "Der Agent ist fertig.", @@ -718,6 +734,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Das erste freie Planungsmodell", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Der vollständige Pfad des Ordners auf diesem Computer. Sein Agent öffnet nur Ordner, die ihm freigegeben wurden.", "The instruction each run is given, on its own: say everything it needs.": "Die Anweisung, die jeder Lauf für sich erhält: Sagen Sie alles, was er braucht.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Nur die Oberfläche. Modelle antworten in der Sprache, in der Sie ihnen schreiben, egal was hier steht.", "The last %(n)s runs failed: %(why)s": "Die letzten %(n)s Läufe sind fehlgeschlagen: %(why)s", "The model's own": "Die des Modells", "The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf", @@ -732,12 +749,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s", "The terminal could not be loaded.": "Das Terminal konnte nicht geladen werden.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Das Terminal braucht einen neueren LLeMbas-Server. Bitten Sie seinen Administrator, ihn zu aktualisieren.", + "Theme": "Design", "These words give no run in the future.": "Diese Worte ergeben keinen künftigen Lauf.", "They run on the computer this chat runs on.": "Sie laufen auf dem Computer, auf dem dieser Chat läuft.", "Think before answering": "Vor der Antwort nachdenken", "Thinking": "Denkt nach", "This computer": "Dieser Computer", "This computer is not linked yet.": "Dieser Computer ist noch nicht verknüpft.", + "This computer's (%(language)s)": "Wie dieser Computer (%(language)s)", "This device is not linked right now.": "Dieses Gerät ist gerade nicht verbunden.", "This file is too big to show here (%(size)s).": "Diese Datei ist zu groß für die Anzeige (%(size)s).", "This file is too large to send.": "Diese Datei ist zu groß zum Senden.", @@ -793,6 +812,7 @@ const LEMBAS_I18N = { "busy": "belegt", "by you": "von dir", "caught up": "nachgeholt", + "dark": "dunkel", "default": "Standard", "done": "fertig", "failed": "fehlgeschlagen", @@ -803,6 +823,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "es bedient eine Anfrage auf einmal und beantwortet gerade eine andere", "its server holds another model now": "sein Server hält gerade ein anderes Modell", "late": "verspätet", + "light": "hell", "messages: %(n)s": "Nachrichten: %(n)s", "not linked": "nicht verbunden", "off": "aus", @@ -870,6 +891,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Un chat de agente trabaja en una carpeta de este equipo, a través del agente integrado en esta aplicación.", "Another device": "Otro dispositivo", "Answer": "Responder", + "Appearance": "Apariencia", "Approve this code in the page that opened in your browser:": "Apruebe este código en la página que se abrió en su navegador:", "Archive chat": "Archivar chat", "Archived": "Archivados", @@ -959,8 +981,11 @@ const LEMBAS_I18N = { "Instruction files": "Archivos de instrucciones", "It has not run yet.": "Todavía no se ha ejecutado.", "It says: %(purpose)s": "Dice: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.", + "Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop necesita un servidor LLeMbas 2.0 o posterior.", + "Language": "Idioma", "Last run": "Última ejecución", "Let the server open a terminal here": "Permitir que el servidor abra un terminal aquí", "Link again": "Vincular de nuevo", @@ -1109,6 +1134,7 @@ const LEMBAS_I18N = { "Tell me": "Avisarme", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte", "Terminal": "Terminal", + "The account's (%(theme)s)": "El de la cuenta (%(theme)s)", "The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:", "The agent asks:": "El agente pregunta:", "The agent has finished.": "El agente ha terminado.", @@ -1126,6 +1152,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "El primer modelo de programación libre", "The folder's full path on that computer. Its agent opens only folders it has been given.": "La ruta completa de la carpeta en ese equipo. Su agente solo abre las carpetas que se le han dado.", "The instruction each run is given, on its own: say everything it needs.": "La instrucción que recibe cada ejecución por sí sola: diga todo lo que necesita.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Solo la interfaz. Los modelos responden en el idioma en el que usted les escribe, diga lo que diga esto.", "The last %(n)s runs failed: %(why)s": "Las últimas %(n)s ejecuciones fallaron: %(why)s", "The model's own": "La del modelo", "The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí", @@ -1140,12 +1167,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s", "The terminal could not be loaded.": "No se ha podido cargar el terminal.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "El terminal necesita un servidor LLeMbas más reciente. Pida a su administrador que lo actualice.", + "Theme": "Tema", "These words give no run in the future.": "Estas palabras no dan ninguna ejecución futura.", "They run on the computer this chat runs on.": "Se ejecutan en el ordenador en el que se ejecuta este chat.", "Think before answering": "Razonar antes de responder", "Thinking": "Pensando", "This computer": "Este equipo", "This computer is not linked yet.": "Este equipo aún no está vinculado.", + "This computer's (%(language)s)": "La de este equipo (%(language)s)", "This device is not linked right now.": "Este dispositivo no está vinculado en este momento.", "This file is too big to show here (%(size)s).": "Este archivo es demasiado grande para mostrarlo (%(size)s).", "This file is too large to send.": "Este archivo es demasiado grande para enviarlo.", @@ -1201,6 +1230,7 @@ const LEMBAS_I18N = { "busy": "ocupado", "by you": "por ti", "caught up": "recuperada", + "dark": "oscuro", "default": "predeterminado", "done": "terminado", "failed": "falló", @@ -1211,6 +1241,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "atiende una petición a la vez y está respondiendo otra", "its server holds another model now": "su servidor tiene ahora otro modelo", "late": "con retraso", + "light": "claro", "messages: %(n)s": "mensajes: %(n)s", "not linked": "no vinculado", "off": "desactivado", @@ -1278,6 +1309,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Une discussion d'agent travaille dans un dossier de cet ordinateur, via l'agent intégré à cette application.", "Another device": "Un autre appareil", "Answer": "Répondre", + "Appearance": "Apparence", "Approve this code in the page that opened in your browser:": "Approuvez ce code sur la page qui s'est ouverte dans votre navigateur :", "Archive chat": "Archiver la conversation", "Archived": "Archivées", @@ -1367,8 +1399,11 @@ const LEMBAS_I18N = { "Instruction files": "Fichiers d’instructions", "It has not run yet.": "Elle ne s'est pas encore exécutée.", "It says: %(purpose)s": "Il dit : %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.", + "Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop nécessite un serveur LLeMbas 2.0 ou plus récent.", + "Language": "Langue", "Last run": "Dernière exécution", "Let the server open a terminal here": "Laisser le serveur ouvrir un terminal ici", "Link again": "Relier à nouveau", @@ -1517,6 +1552,7 @@ const LEMBAS_I18N = { "Tell me": "Me prévenir", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs", "Terminal": "Terminal", + "The account's (%(theme)s)": "Celui du compte (%(theme)s)", "The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :", "The agent asks:": "L'agent demande :", "The agent has finished.": "L'agent a terminé.", @@ -1534,6 +1570,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Le premier modèle de planification libre", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Le chemin complet du dossier sur cet ordinateur. Son agent n’ouvre que les dossiers qui lui ont été confiés.", "The instruction each run is given, on its own: say everything it needs.": "L'instruction donnée à chaque exécution, seule : dites tout ce dont elle a besoin.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "L'interface uniquement. Les modèles répondent dans la langue dans laquelle vous leur écrivez, quoi qu'indique ce réglage.", "The last %(n)s runs failed: %(why)s": "Les %(n)s dernières exécutions ont échoué : %(why)s", "The model's own": "Celle du modèle", "The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici", @@ -1548,12 +1585,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s", "The terminal could not be loaded.": "Le terminal n'a pas pu être chargé.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Le terminal nécessite un serveur LLeMbas plus récent. Demandez à son administrateur de le mettre à jour.", + "Theme": "Thème", "These words give no run in the future.": "Ces mots ne donnent aucune exécution à venir.", "They run on the computer this chat runs on.": "Ils tournent sur l’ordinateur où tourne cette conversation.", "Think before answering": "Réfléchir avant de répondre", "Thinking": "Réflexion", "This computer": "Cet ordinateur", "This computer is not linked yet.": "Cet ordinateur n’est pas encore lié.", + "This computer's (%(language)s)": "Celle de cet ordinateur (%(language)s)", "This device is not linked right now.": "Cet appareil n'est pas relié pour le moment.", "This file is too big to show here (%(size)s).": "Ce fichier est trop volumineux pour être affiché (%(size)s).", "This file is too large to send.": "Ce fichier est trop volumineux pour être envoyé.", @@ -1609,6 +1648,7 @@ const LEMBAS_I18N = { "busy": "occupé", "by you": "par vous", "caught up": "rattrapée", + "dark": "sombre", "default": "par défaut", "done": "terminé", "failed": "échoué", @@ -1619,6 +1659,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "il traite une requête à la fois et en traite une autre", "its server holds another model now": "son serveur a maintenant un autre modèle chargé", "late": "en retard", + "light": "clair", "messages: %(n)s": "messages : %(n)s", "not linked": "non relié", "off": "désactivé", @@ -1686,6 +1727,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Az ügynökcsevegés egy mappában dolgozik ezen a számítógépen, az alkalmazásba épített ügynökön keresztül.", "Another device": "Másik eszköz", "Answer": "Válasz", + "Appearance": "Megjelenés", "Approve this code in the page that opened in your browser:": "Hagyja jóvá ezt a kódot a böngészőjében megnyílt oldalon:", "Archive chat": "Csevegés archiválása", "Archived": "Archivált", @@ -1775,8 +1817,11 @@ const LEMBAS_I18N = { "Instruction files": "Utasításfájlok", "It has not run yet.": "Még nem futott.", "It says: %(purpose)s": "Ezt mondja: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.", + "Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.", "LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "A LLeMbas Desktophoz LLeMbas 2.0 vagy újabb szerver kell.", + "Language": "Nyelv", "Last run": "Utolsó futtatás", "Let the server open a terminal here": "A kiszolgáló nyithasson itt terminált", "Link again": "Újra összekapcsolás", @@ -1925,6 +1970,7 @@ const LEMBAS_I18N = { "Tell me": "Értesítés", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok", "Terminal": "Terminál", + "The account's (%(theme)s)": "A fióké (%(theme)s)", "The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:", "The agent asks:": "Az ügynök kérdezi:", "The agent has finished.": "Az ügynök befejezte.", @@ -1942,6 +1988,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Az első szabad ütemezési modell", "The folder's full path on that computer. Its agent opens only folders it has been given.": "A mappa teljes elérési útja azon a számítógépen. Az ügynöke csak a neki megadott mappákat nyitja meg.", "The instruction each run is given, on its own: say everything it needs.": "Az utasítás, amelyet minden futás önmagában kap: mondjon el mindent, amire szüksége van.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Csak a felület. A modellek azon a nyelven válaszolnak, amelyen ír nekik, függetlenül ettől a beállítástól.", "The last %(n)s runs failed: %(why)s": "Az utolsó %(n)s futás sikertelen: %(why)s", "The model's own": "A modell sajátja", "The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet", @@ -1956,12 +2003,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s", "The terminal could not be loaded.": "A terminált nem sikerült betölteni.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "A terminálhoz újabb LLeMbas-szerver kell. Kérje meg a rendszergazdáját, hogy frissítse.", + "Theme": "Téma", "These words give no run in the future.": "Ezekből a szavakból nem következik jövőbeli futás.", "They run on the computer this chat runs on.": "Azon a számítógépen futnak, amelyen ez a beszélgetés fut.", "Think before answering": "Gondolkodjon válasz előtt", "Thinking": "Gondolkodás", "This computer": "Ez a számítógép", "This computer is not linked yet.": "Ez a számítógép még nincs összekapcsolva.", + "This computer's (%(language)s)": "A számítógépé (%(language)s)", "This device is not linked right now.": "Ez az eszköz jelenleg nincs összekapcsolva.", "This file is too big to show here (%(size)s).": "Ez a fájl túl nagy a megjelenítéshez (%(size)s).", "This file is too large to send.": "Ez a fájl túl nagy a küldéshez.", @@ -2017,6 +2066,7 @@ const LEMBAS_I18N = { "busy": "foglalt", "by you": "általad", "caught up": "pótolva", + "dark": "sötét", "default": "alapértelmezett", "done": "kész", "failed": "sikertelen", @@ -2027,6 +2077,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "egyszerre egy kérést szolgál ki, és éppen egy másikat válaszol meg", "its server holds another model now": "a szervere most egy másik modellt tart betöltve", "late": "késve", + "light": "világos", "messages: %(n)s": "üzenetek: %(n)s", "not linked": "nincs összekapcsolva", "off": "ki", @@ -2094,6 +2145,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Una chat con agente lavora in una cartella di questo computer, tramite l'agente integrato in questa app.", "Another device": "Un altro dispositivo", "Answer": "Rispondi", + "Appearance": "Aspetto", "Approve this code in the page that opened in your browser:": "Approvi questo codice nella pagina che si è aperta nel suo browser:", "Archive chat": "Archivia chat", "Archived": "Archiviate", @@ -2183,8 +2235,11 @@ const LEMBAS_I18N = { "Instruction files": "File di istruzioni", "It has not run yet.": "Non è ancora stata eseguita.", "It says: %(purpose)s": "Dice: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.", + "Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.", "LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop richiede un server LLeMbas 2.0 o successivo.", + "Language": "Lingua", "Last run": "Ultima esecuzione", "Let the server open a terminal here": "Lascia che il server apra un terminale qui", "Link again": "Collega di nuovo", @@ -2333,6 +2388,7 @@ const LEMBAS_I18N = { "Tell me": "Avvisami", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove", "Terminal": "Terminale", + "The account's (%(theme)s)": "Quello dell’account (%(theme)s)", "The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:", "The agent asks:": "L'agente chiede:", "The agent has finished.": "L'agente ha finito.", @@ -2350,6 +2406,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Il primo modello di pianificazione libero", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Il percorso completo della cartella su quel computer. Il suo agente apre solo le cartelle che gli sono state date.", "The instruction each run is given, on its own: say everything it needs.": "L’istruzione che ogni esecuzione riceve da sola: di’ tutto ciò che le serve.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Solo l'interfaccia. I modelli rispondono nella lingua in cui scrivi loro, qualunque cosa dica questa impostazione.", "The last %(n)s runs failed: %(why)s": "Le ultime %(n)s esecuzioni non sono riuscite: %(why)s", "The model's own": "Quello del modello", "The most an agent chat may do here": "Il massimo che una chat agente può fare qui", @@ -2364,12 +2421,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s", "The terminal could not be loaded.": "Impossibile caricare il terminale.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Il terminale richiede un server LLeMbas più recente. Chiedi al suo amministratore di aggiornarlo.", + "Theme": "Tema", "These words give no run in the future.": "Queste parole non danno nessuna esecuzione futura.", "They run on the computer this chat runs on.": "Girano sul computer su cui gira questa chat.", "Think before answering": "Ragiona prima di rispondere", "Thinking": "Ragionamento", "This computer": "Questo computer", "This computer is not linked yet.": "Questo computer non è ancora collegato.", + "This computer's (%(language)s)": "Quella di questo computer (%(language)s)", "This device is not linked right now.": "Questo dispositivo non è collegato in questo momento.", "This file is too big to show here (%(size)s).": "Questo file è troppo grande per essere mostrato (%(size)s).", "This file is too large to send.": "Questo file è troppo grande per essere inviato.", @@ -2425,6 +2484,7 @@ const LEMBAS_I18N = { "busy": "occupato", "by you": "da te", "caught up": "recuperata", + "dark": "scuro", "default": "predefinito", "done": "finito", "failed": "fallito", @@ -2435,6 +2495,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "serve una richiesta alla volta e ne sta servendo un’altra", "its server holds another model now": "il suo server ha ora un altro modello", "late": "in ritardo", + "light": "chiaro", "messages: %(n)s": "messaggi: %(n)s", "not linked": "non collegato", "off": "disattivato", @@ -2502,6 +2563,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Czat z agentem pracuje w folderze na tym komputerze, przez agenta wbudowanego w tę aplikację.", "Another device": "Inne urządzenie", "Answer": "Odpowiedź", + "Appearance": "Wygląd", "Approve this code in the page that opened in your browser:": "Zatwierdź ten kod na stronie, która otworzyła się w przeglądarce:", "Archive chat": "Archiwizuj czat", "Archived": "Zarchiwizowane", @@ -2591,8 +2653,11 @@ const LEMBAS_I18N = { "Instruction files": "Pliki z instrukcjami", "It has not run yet.": "Jeszcze się nie uruchomił.", "It says: %(purpose)s": "Mówi: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.", + "Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.", "LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop wymaga serwera LLeMbas 2.0 lub nowszego.", + "Language": "Język", "Last run": "Ostatni przebieg", "Let the server open a terminal here": "Pozwól serwerowi otworzyć tu terminal", "Link again": "Połącz ponownie", @@ -2741,6 +2806,7 @@ const LEMBAS_I18N = { "Tell me": "Powiadamiaj", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej", "Terminal": "Terminal", + "The account's (%(theme)s)": "Jak na koncie (%(theme)s)", "The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:", "The agent asks:": "Agent pyta:", "The agent has finished.": "Agent skończył.", @@ -2758,6 +2824,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Pierwszy wolny model do planowania", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Pełna ścieżka folderu na tym komputerze. Jego agent otwiera tylko foldery, które mu udostępniono.", "The instruction each run is given, on its own: say everything it needs.": "Instrukcja, którą każde uruchomienie dostaje samodzielnie: podaj wszystko, czego potrzebuje.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Tylko interfejs. Modele odpowiadają w języku, w którym się do nich pisze, bez względu na to ustawienie.", "The last %(n)s runs failed: %(why)s": "Ostatnie uruchomienia nie powiodły się (%(n)s): %(why)s", "The model's own": "Domyślne modelu", "The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić", @@ -2772,12 +2839,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s", "The terminal could not be loaded.": "Nie udało się wczytać terminala.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminal wymaga nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.", + "Theme": "Motyw", "These words give no run in the future.": "Z tych słów nie wynika żadne przyszłe uruchomienie.", "They run on the computer this chat runs on.": "Działają na komputerze, na którym działa ta rozmowa.", "Think before answering": "Rozumuj przed odpowiedzią", "Thinking": "Rozumowanie", "This computer": "Ten komputer", "This computer is not linked yet.": "Ten komputer nie jest jeszcze połączony.", + "This computer's (%(language)s)": "Jak na tym komputerze (%(language)s)", "This device is not linked right now.": "To urządzenie nie jest teraz połączone.", "This file is too big to show here (%(size)s).": "Ten plik jest za duży, by go tu pokazać (%(size)s).", "This file is too large to send.": "Ten plik jest za duży, aby go wysłać.", @@ -2833,6 +2902,7 @@ const LEMBAS_I18N = { "busy": "zajęty", "by you": "przez ciebie", "caught up": "nadrobiony", + "dark": "ciemny", "default": "domyślna", "done": "gotowe", "failed": "nie powiodło się", @@ -2843,6 +2913,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "obsługuje jedno żądanie naraz i właśnie obsługuje inne", "its server holds another model now": "jego serwer ma teraz wczytany inny model", "late": "z opóźnieniem", + "light": "jasny", "messages: %(n)s": "wiadomości: %(n)s", "not linked": "niepołączony", "off": "wył.", @@ -2910,6 +2981,7 @@ const LEMBAS_I18N = { "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentská konverzácia pracuje v priečinku na tomto počítači cez agenta vstavaného v tejto aplikácii.", "Another device": "Iné zariadenie", "Answer": "Odpovedať", + "Appearance": "Vzhľad", "Approve this code in the page that opened in your browser:": "Potvrďte tento kód na stránke, ktorá sa otvorila vo vašom prehliadači:", "Archive chat": "Archivovať konverzáciu", "Archived": "Archivované", @@ -2999,8 +3071,11 @@ const LEMBAS_I18N = { "Instruction files": "Súbory s pokynmi", "It has not run yet.": "Ešte nebežal.", "It says: %(purpose)s": "Model uvádza: %(purpose)s", + "Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.", + "Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.", "LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)", "LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potrebuje server LLeMbas 2.0 alebo novší.", + "Language": "Jazyk", "Last run": "Posledný beh", "Let the server open a terminal here": "Dovoliť serveru otvoriť tu terminál", "Link again": "Prepojiť znova", @@ -3149,6 +3224,7 @@ const LEMBAS_I18N = { "Tell me": "Upozorniť ma", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde", "Terminal": "Terminál", + "The account's (%(theme)s)": "Podľa účtu (%(theme)s)", "The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:", "The agent asks:": "Agent sa pýta:", "The agent has finished.": "Agent dokončil prácu.", @@ -3166,6 +3242,7 @@ const LEMBAS_I18N = { "The first free scheduling model": "Prvý voľný model na plánovanie", "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.", "The instruction each run is given, on its own: say everything it needs.": "Inštrukcia, ktorú dostane každé spustenie samostatne: uveďte všetko, čo potrebuje.", + "The interface only. Models answer in the language you write to them in, whatever this says.": "Len rozhranie. Modely odpovedajú v jazyku, v ktorom im píšete, bez ohľadu na toto nastavenie.", "The last %(n)s runs failed: %(why)s": "Posledné spustenia zlyhali (%(n)s): %(why)s", "The model's own": "Podľa modelu", "The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie", @@ -3180,12 +3257,14 @@ const LEMBAS_I18N = { "The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s", "The terminal could not be loaded.": "Terminál sa nepodarilo načítať.", "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potrebuje novší server LLeMbas. Požiadajte jeho správcu o aktualizáciu.", + "Theme": "Vzhľad", "These words give no run in the future.": "Z týchto slov nevychádza žiadne budúce spustenie.", "They run on the computer this chat runs on.": "Bežia na počítači, na ktorom beží táto konverzácia.", "Think before answering": "Premýšľať pred odpoveďou", "Thinking": "Premýšľa", "This computer": "Tento počítač", "This computer is not linked yet.": "Tento počítač ešte nie je prepojený.", + "This computer's (%(language)s)": "Podľa tohto počítača (%(language)s)", "This device is not linked right now.": "Toto zariadenie teraz nie je pripojené.", "This file is too big to show here (%(size)s).": "Tento súbor je príliš veľký na zobrazenie (%(size)s).", "This file is too large to send.": "Tento súbor je príliš veľký na odoslanie.", @@ -3241,6 +3320,7 @@ const LEMBAS_I18N = { "busy": "zaneprázdnený", "by you": "vami", "caught up": "dohnané", + "dark": "tmavá", "default": "predvolené", "done": "hotovo", "failed": "zlyhalo", @@ -3251,6 +3331,7 @@ const LEMBAS_I18N = { "it serves one request at a time and is answering another": "vybavuje jednu požiadavku naraz a práve vybavuje inú", "its server holds another model now": "jeho server má teraz načítaný iný model", "late": "s oneskorením", + "light": "svetlá", "messages: %(n)s": "správy: %(n)s", "not linked": "neprepojené", "off": "vypnuté", @@ -3276,10 +3357,22 @@ const LEMBAS_I18N = { } } -const LEMBAS_LANG = (() => { +// Each language's name for itself, for the choice in Settings. +const LEMBAS_LANGS = {"cs": "Čeština", "de": "Deutsch", "en": "English", "es": "Español", "fr": "Français", "hu": "Magyar", "it": "Italiano", "pl": "Polski", "sk": "Slovenčina"} + +// What the system's language gives, and what was chosen here instead ("" for the system's). +const LEMBAS_SYSTEM_LANG = (() => { const code = String(navigator.language || "").slice(0, 2).toLowerCase() return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en" })() +const LEMBAS_CHOSEN_LANG = (() => { + let code = "" + try { + code = localStorage.getItem("lembas.language") || "" + } catch {} + return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "" +})() +const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG document.documentElement.lang = LEMBAS_LANG function t(text, fields) { diff --git a/ui/js/bridge.js b/ui/js/bridge.js index 9c96ef6..df09a0a 100644 --- a/ui/js/bridge.js +++ b/ui/js/bridge.js @@ -125,3 +125,10 @@ export async function openTerminal(target, cols, rows, { onOutput, onControl, on export function openLink(url) { return invoke("open_external", { url }).catch(() => {}) } + +/** Let go of every reply followed and every shell open (the page is about to load again). */ +export function letGoAll() { + const ids = [...followers.keys()].map((id) => invoke("stream_close", { id })) + const shells = [...terminals.keys()].map((id) => invoke("terminal_close", { id })) + return Promise.allSettled([...ids, ...shells]) +} diff --git a/ui/js/dom.js b/ui/js/dom.js index 74a86ac..52be16d 100644 --- a/ui/js/dom.js +++ b/ui/js/dom.js @@ -100,6 +100,7 @@ export const ICONS = { check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z", bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"], archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"], + contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"], info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"], } diff --git a/ui/js/look.js b/ui/js/look.js new file mode 100644 index 0000000..a8d0ca9 --- /dev/null +++ b/ui/js/look.js @@ -0,0 +1,77 @@ +// The app's language and theme, as chosen in Settings: kept in this app (localStorage), over the +// system's language and the account's theme. The account's own language and theme are the +// server's to change (`PATCH /me` has neither yet), so a choice here stays on this computer. +// +// The language is read once, as the page loads (ui/i18n.js, `lembas.language`): every sentence is +// drawn through `t()` as it is needed, so a new one opens the window again. The theme is applied +// at once. + +import { invoke, letGoAll } from "./bridge.js" +import { terminalThemeChanged } from "./terminal.js" + +const THEMES = ["moria", "shire"] +let account = "moria" // the account's theme, as /me says (a custom one counts as Moria) + +function read(key) { + try { + return localStorage.getItem(key) || "" + } catch { + return "" + } +} + +function write(key, value) { + try { + if (value) localStorage.setItem(key, value) + else localStorage.removeItem(key) + } catch {} +} + +/** The theme chosen in this app ("" for the account's). */ +export function chosenTheme() { + const name = read("lembas.theme") + return THEMES.includes(name) ? name : "" +} + +/** The account's theme, as the server says it. */ +export function accountTheme() { + return account +} + +/** The look the window is drawn in: the one chosen here, else `name` (the account's, or Rust's last). */ +export function applyTheme(name) { + name = chosenTheme() || name + if (!THEMES.includes(name)) name = "moria" + const root = document.documentElement + root.dataset.theme = name + root.dataset.base = name + terminalThemeChanged() + return name +} + +/** The account's theme is known (from /me): drawn in it, unless one is chosen here. */ +export function noteAccountTheme(name) { + account = THEMES.includes(name) ? name : "moria" + invoke("set_theme", { name: applyTheme(account) }) +} + +/** Choose a theme here ("" goes back to the account's): drawn at once, and the window painted in it. */ +export function chooseTheme(name) { + write("lembas.theme", THEMES.includes(name) ? name : "") + invoke("set_theme", { name: applyTheme(account) }) +} + +/** The language chosen in this app ("" for the system's). */ +export function chosenLanguage() { + return typeof LEMBAS_CHOSEN_LANG === "string" ? LEMBAS_CHOSEN_LANG : "" +} + +/** Choose a language here ("" goes back to the system's): the window opens again in it. */ +export async function chooseLanguage(code) { + if (code === chosenLanguage()) return + write("lembas.language", code && Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "") + // The replies followed and the shells open are let go first: the page that opens again has + // no handle on them. + await letGoAll() + location.reload() +} diff --git a/ui/js/main.js b/ui/js/main.js index ea429fc..4501cc9 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -13,7 +13,8 @@ import { initViewer, onViewer, closeViewer } from "./viewer.js" import { initExplorer, showLocal, showFilesTab } from "./explorer.js" import { initList, refreshList, markCurrent } from "./list.js" import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js" -import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js" +import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js" +import { applyTheme, noteAccountTheme } from "./look.js" import { initAttach, allowAttach } from "./attach.js" import { initPalette, openPalette } from "./palette.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" @@ -64,14 +65,6 @@ function thisDevices() { return remembered.slice() } -function applyTheme(name) { - if (name !== "moria" && name !== "shire") name = "moria" - const root = document.documentElement - root.dataset.theme = name - root.dataset.base = name - terminalThemeChanged() -} - // --- Views ------------------------------------------------------------------------------------ function route() { @@ -99,8 +92,7 @@ async function startApp() { started = true try { me = await get("/me") - applyTheme(me.theme) - invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" }) + noteAccountTheme(me.theme) } catch (err) { $("thread-note").textContent = why(err) $("thread-note").hidden = false diff --git a/ui/js/settings.js b/ui/js/settings.js index 22efcc7..91a3214 100644 --- a/ui/js/settings.js +++ b/ui/js/settings.js @@ -1,10 +1,12 @@ // Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone // sees). Grouped, with the groups listed beside them: the account (the server and the app's -// sign-in), the agent and the folders it may work in, notifications, and this release. +// sign-in), the agent and the folders it may work in, the language and theme, notifications, and +// this release. import { $, h, clear, icon, ICONS } from "./dom.js" import { invoke, listen } from "./bridge.js" import { agentBlock, onAgentChange, link } from "./agent.js" +import { chosenTheme, chooseTheme, accountTheme, chosenLanguage, chooseLanguage } from "./look.js" let folderError = "" let current = () => null @@ -13,6 +15,7 @@ const SECTIONS = [ ["account", ICONS.user, () => t("Account")], ["agent", ICONS.terminal, () => t("Agent")], ["folders", ICONS.folder, () => t("Project folders")], + ["look", ICONS.contrast, () => t("Appearance")], ["notify", ICONS.bell, () => t("Notifications")], ["about", ICONS.info, () => t("About")], ] @@ -118,6 +121,8 @@ export function renderSettings(s) { ) } + box.append(lookSection()) + box.append( section( "notify", @@ -143,6 +148,30 @@ export function renderSettings(s) { ) } +/** The language and the theme: this app's choice over the system's language and the account's theme. */ +function lookSection() { + const named = (code) => LEMBAS_LANGS[code] || code + const language = h( + "select", + { id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) }, + [["", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })], ...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)])].map(([v, label]) => + h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v || undefined }), + ), + ) + const label = (name) => `${name === "shire" ? "Shire" : "Moria"} — ${name === "shire" ? t("light") : t("dark")}` + const theme = h( + "select", + { id: "set-theme", "aria-label": t("Theme"), onchange: (e) => chooseTheme(e.target.value) }, + [["", t("The account's (%(theme)s)", { theme: label(accountTheme()) })], ["moria", label("moria")], ["shire", label("shire")]].map(([v, text]) => h("option", { value: v, text, selected: chosenTheme() === v })), + ) + return section( + "look", + t("Appearance"), + line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language), + line(t("Theme"), t("Kept in this app on this computer; the account's theme stays as it is."), theme), + ) +} + export function initSettings(getStatus) { current = getStatus listen("remote-error", (e) => {